Files
Video-Tree-TRM5/reference/video_tree_trm/text_tree_builder.py
T
iomgaa 6bdb802f01 chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.)
- Add claude hooks (pre-commit-guard, post-edit-quality)
- Add research templates (experiment plan, research brief, etc.)
- Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa)
- Add TRM4 reference implementation as algorithm fidelity baseline
- Add research-wiki content (plans, index, graph, query_pack)
- Update .gitignore to exclude .graphify_version runtime state
2026-07-06 20:59:03 -04:00

467 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
文本树构建模块
==============
将长文本通过 L2 轴心策略转化为三层 TreeIndex。
构建策略::
Step 1: _segment_text — 结构切分,确定 L1/L2 边界
Step 2: L2 先行 — 从原始内容独立生成 L2 摘要(batch_chat 并发)
Step 3: L3 向下 — 原始段落文本直接作为 L3,无需二次生成
Step 4: L1 向上 — 聚合 L2 描述,生成 L1 粗粒度摘要
Step 5: 组装 TreeIndex
L2 轴心策略解决了循环依赖:
- L2 描述不依赖 L3,从原始段落直接生成
- L3 直接使用原始段落文本,不调用 LLM
- L1 聚合 L2 描述,保证完整覆盖
使用方式::
builder = TextTreeBuilder(embed_model, llm_client, config.tree)
index = builder.build(text, source_path="docs/my_doc.txt")
index.save("cache/my_doc.pkl")
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import List, Tuple
import numpy as np
from utils.logger_system import ensure, log_json, log_msg
from video_tree_trm.config import TreeConfig
from video_tree_trm.llm_client import LLMClient
from video_tree_trm.tree_index import (
IndexMeta,
L1Node,
L2Node,
L3Node,
TreeIndex,
)
# ---------------------------------------------------------------------------
# Prompt 常量
# ---------------------------------------------------------------------------
_L2_PROMPT = (
"用1-2句话描述以下段落的核心内容,与同级小节形成区分:\n\n{text}"
)
_L1_PROMPT = (
"用2-3句话总结以下小节的核心内容:\n\n{l2_descriptions}"
)
_SEG_PROMPT = (
"将以下文本分成若干语义段落,每段为完整语义单元。\n"
"只返回 JSON 数组,格式: [\"段落1\", \"段落2\", ...],不要其他内容。\n"
"文本:\n\n{text}"
)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _chunk(lst: List[str], size: int) -> List[List[str]]:
"""将列表等长分块(固定步长,无重叠)。
参数:
lst: 待分块的列表。
size: 每块的最大长度。
返回:
分块后的列表,每个元素为一个子列表。
"""
return [lst[i : i + size] for i in range(0, len(lst), size)]
# ---------------------------------------------------------------------------
# 主类
# ---------------------------------------------------------------------------
class TextTreeBuilder:
"""文本模态树构建器。
将长文本通过 L2 轴心策略(先构建 L2,再向下扩展 L3,向上聚合 L1)
转化为三层 TreeIndex。节点 embedding 均为 None(由 Pipeline.embed_all 延迟填充)。
属性:
llm: LLM 客户端。
config: 树构建配置。
"""
def __init__(
self,
llm: LLMClient,
config: TreeConfig,
) -> None:
"""初始化文本树构建器。
参数:
llm: 已初始化的 LLM 客户端(LLMClient)。
config: 树构建配置(TreeConfig),关键字段 max_paragraphs_per_l2。
实现细节:
构建器不持有 EmbeddingModel,所有 embedding 延迟到检索阶段由 Pipeline 统一计算。
"""
self.llm = llm
self.config = config
# ------------------------------------------------------------------
# 公共接口
# ------------------------------------------------------------------
def build(self, text: str, source_path: str) -> TreeIndex:
"""将长文本构建为三层 TreeIndex。
参数:
text: 输入长文本(UTF-8 字符串)。
source_path: 原始文件路径,写入 IndexMeta。
返回:
三层 TreeIndex 对象。
实现细节:
1. _segment_text 切分文本 → List[List[str]](外层=L1,内层=L2段落组)
2. 将所有 L2 段落组的 prompt 批量送入 llm.batch_chat(),并发获取摘要
3. 逐层组装 L3→L2→L1 节点
4. 构建 TreeIndex 并写入日志
"""
ensure(bool(text.strip()), "输入文本不能为空")
log_msg("INFO", "开始构建文本树索引", source_path=source_path)
# Phase 1: 结构切分
sections = self._segment_text(text)
ensure(len(sections) > 0, "文本切分结果为空")
log_msg(
"INFO",
"文本切分完成",
l1_count=len(sections),
l2_groups=[len(s) for s in sections],
)
# Phase 2: 收集所有 L2 段落组,批量生成摘要(L2 先行)
all_groups: List[Tuple[int, int, List[str]]] = []
for i, section_paragraphs in enumerate(sections):
for j, group in enumerate(
_chunk(section_paragraphs, self.config.max_paragraphs_per_l2)
):
all_groups.append((i, j, group))
l2_prompts = [
_L2_PROMPT.format(text="\n\n".join(group))
for _, _, group in all_groups
]
l2_descs = self.llm.batch_chat(l2_prompts)
log_msg("INFO", "L2 摘要生成完成", total_l2=len(l2_descs))
# Phase 3-4: 按 L1 组装三层节点
# 构建索引映射:(i, j) → 在 all_groups / l2_descs 中的位置
group_index: dict = {
(i, j): idx for idx, (i, j, _) in enumerate(all_groups)
}
l1_nodes: List[L1Node] = []
for i, section_paragraphs in enumerate(sections):
groups = _chunk(section_paragraphs, self.config.max_paragraphs_per_l2)
l2_nodes: List[L2Node] = []
for j, group in enumerate(groups):
idx = group_index[(i, j)]
desc = l2_descs[idx]
l3_nodes = self._build_l3_from_paragraphs(group, i, j)
l2_node = L2Node(
id=f"l1_{i}_l2_{j}",
description=desc,
embedding=None,
time_range=None,
children=l3_nodes,
)
l2_nodes.append(l2_node)
l1_node = self._build_l1(l2_nodes, f"l1_{i}")
l1_nodes.append(l1_node)
# Phase 5: 组装 TreeIndexembedding 延迟到 Pipeline.embed_all,此处为 None
metadata = IndexMeta(
source_path=source_path,
modality="text",
created_at=datetime.now().isoformat(),
)
index = TreeIndex(metadata=metadata, roots=l1_nodes)
total_l2 = sum(len(r.children) for r in l1_nodes)
total_l3 = sum(
len(l2.children) for r in l1_nodes for l2 in r.children
)
log_json(
"text_tree_build",
{
"source_path": source_path,
"l1_count": len(l1_nodes),
"l2_count": total_l2,
"l3_count": total_l3,
"embedded": False,
},
)
log_msg(
"INFO",
"文本树索引构建完成",
l1=len(l1_nodes),
l2=total_l2,
l3=total_l3,
)
return index
# ------------------------------------------------------------------
# 内部方法:切分策略
# ------------------------------------------------------------------
def _segment_text(self, text: str) -> List[List[str]]:
"""结构切分长文本为 L1/L2 层次。
参数:
text: 输入文本。
返回:
sections[i] = [paragraph_1, paragraph_2, ...]
外层列表 = L1 段(章节),内层列表 = L2 单元(段落组内段落)。
策略:
有 Markdown 标题 → 正则解析 #/## 边界
无 Markdown 标题 → LLM 单次调用语义分段
"""
if self._detect_toc(text):
log_msg("INFO", "检测到 Markdown 标题,使用正则切分")
return self._segment_with_regex(text)
else:
log_msg("INFO", "未检测到 Markdown 标题,使用 LLM 语义分段")
return self._segment_with_llm(text)
def _detect_toc(self, text: str) -> bool:
"""检测文本是否包含 Markdown 标题(有 ToC 结构)。
参数:
text: 输入文本。
返回:
True 表示有 # 或 ## 开头的标题行,False 表示无。
"""
return bool(re.search(r"^#{1,2}\s+\S", text, re.MULTILINE))
def _segment_with_regex(self, text: str) -> List[List[str]]:
"""通过正则解析 Markdown 标题边界进行结构切分。
参数:
text: 含 Markdown 标题的文本。
返回:
List[List[str]],外层=L1章节,内层=该章节下的段落列表。
若二级标题下段落数超过 max_paragraphs_per_l2,则进一步等长分块。
实现细节:
- # 标题 → L1 边界
- ## 标题 → L2 边界
- ### 及以下标题视为段落内容,收集到最近 L2 段落组
- 空段落过滤掉
"""
lines = text.split("\n")
sections: List[List[str]] = [] # 外层=L1
current_section: List[str] = [] # 当前 L1 下的段落(扁平)
current_para_lines: List[str] = [] # 积累段落文本行
def _flush_para() -> None:
"""将当前积累的行合并为一个段落加入 current_section。"""
para = "\n".join(current_para_lines).strip()
if para:
current_section.append(para)
current_para_lines.clear()
def _flush_section() -> None:
"""将当前 section 保存,重置。"""
_flush_para()
if current_section:
sections.append(list(current_section))
current_section.clear()
for line in lines:
h1_match = re.match(r"^#\s+(.+)", line)
h2_match = re.match(r"^##\s+(.+)", line)
if h1_match:
# L1 边界:保存当前 section
_flush_section()
# 将 H1 标题本身作为第一段落(可选:也可忽略标题行)
title = h1_match.group(1).strip()
if title:
current_section.append(title)
elif h2_match:
# L2 边界:只冲刷当前段落,不切换 section
_flush_para()
title = h2_match.group(1).strip()
if title:
current_section.append(title)
else:
# 普通内容行(含 ###、####、正文段落)
if line.strip() == "":
# 空行触发段落分隔
_flush_para()
else:
current_para_lines.append(line)
_flush_section()
# 若没有产生任何 section(如文本只有一个 L1),保底处理
if not sections:
sections = [self._collect_paragraphs(text)]
# 对超出 max_paragraphs_per_l2 的段落组不做处理(由 build() 负责分块)
return sections
def _segment_with_llm(self, text: str) -> List[List[str]]:
"""通过 LLM 单次调用语义分段无结构文本。
参数:
text: 无 Markdown 标题的纯文本。
返回:
List[List[str]],只有一个外层元素(整篇视为单个 L1)。
内层为 LLM 返回的语义段落列表。
异常:
ValueError: LLM 返回的内容无法解析为 JSON 数组。
"""
prompt = _SEG_PROMPT.format(text=text)
raw = self.llm.chat(prompt)
# 尝试从返回结果中提取 JSON 数组
raw = raw.strip()
# 提取可能被 markdown 代码块包裹的 JSON
code_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, re.DOTALL)
if code_match:
raw = code_match.group(1)
ensure(
raw.startswith("["),
f"LLM 语义分段返回格式错误,期望 JSON 数组,实际: {raw[:100]}",
)
try:
paragraphs: List[str] = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"LLM 语义分段 JSON 解析失败: {e}\n原始输出: {raw}") from e
ensure(isinstance(paragraphs, list), "LLM 返回值不是列表")
ensure(len(paragraphs) > 0, "LLM 语义分段返回空列表")
# 过滤空段落
paragraphs = [p.strip() for p in paragraphs if p.strip()]
log_msg("INFO", "LLM 语义分段完成", paragraph_count=len(paragraphs))
return [paragraphs] # 整篇视为单个 L1
def _collect_paragraphs(self, text: str) -> List[str]:
"""按双换行符切分段落(保底策略)。
参数:
text: 输入文本。
返回:
非空段落列表。
"""
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
return paras if paras else [text.strip()]
# ------------------------------------------------------------------
# 内部方法:节点构建
# ------------------------------------------------------------------
def _build_l2(self, paragraphs: List[str], l2_id: str) -> L2Node:
"""将段落组构建为 L2 节点(含 LLM 摘要和嵌入)。
参数:
paragraphs: 该 L2 节点下的段落文本列表。
l2_id: 节点 ID。
返回:
L2Nodechildren 为空,由调用方填充)。
"""
ensure(len(paragraphs) > 0, f"L2 节点 {l2_id} 的段落列表为空")
prompt = _L2_PROMPT.format(text="\n\n".join(paragraphs))
description = self.llm.chat(prompt)
return L2Node(
id=l2_id,
description=description,
embedding=None,
time_range=None,
)
def _build_l3_from_paragraphs(
self,
paragraphs: List[str],
l1_i: int,
l2_j: int,
) -> List[L3Node]:
"""将段落列表批量构建为 L3 节点(原始文本直接复用,不调用 LLM)。
参数:
paragraphs: 段落文本列表。
l1_i: 父 L1 索引(用于生成 ID)。
l2_j: 父 L2 索引(用于生成 ID)。
返回:
L3Node 列表,description == raw_content == 原始段落文本。
实现细节:
使用 embed.embed(paragraphs) 批量嵌入,一次调用获取全部向量。
"""
ensure(len(paragraphs) > 0, f"L3 段落列表为空 (l1={l1_i}, l2={l2_j})")
nodes: List[L3Node] = []
for k, para in enumerate(paragraphs):
nodes.append(
L3Node(
id=f"l1_{l1_i}_l2_{l2_j}_l3_{k}",
description=para,
embedding=None,
raw_content=para,
frame_path=None,
timestamp=None,
)
)
return nodes
def _build_l1(self, l2_children: List[L2Node], l1_id: str) -> L1Node:
"""聚合 L2 描述,构建 L1 节点(含 LLM 摘要和嵌入)。
参数:
l2_children: 该 L1 节点下的所有 L2 节点。
l1_id: 节点 ID。
返回:
L1Nodechildren 已由调用方赋值,或在此赋值)。
实现细节:
将所有 L2 描述拼接,用序号标注后送入 LLM 生成 2-3 句摘要。
"""
ensure(len(l2_children) > 0, f"L1 节点 {l1_id} 没有 L2 子节点")
l2_descriptions = "\n".join(
f"{idx + 1}. {node.description}"
for idx, node in enumerate(l2_children)
)
prompt = _L1_PROMPT.format(l2_descriptions=l2_descriptions)
summary = self.llm.chat(prompt)
return L1Node(
id=l1_id,
summary=summary,
embedding=None,
time_range=None,
children=l2_children,
)