"""三层树索引核心数据结构。 定义 Video-Tree-TRM 的三层树状索引结构,是所有后续模块 (builder、retriever、harness、search)的基础依赖。 数据结构层次:: TreeIndex └─ List[L1Node] 全局叙事节点 └─ List[L2Node] 片段级语义节点 └─ List[L3Node] 帧/细节级节点 与参考项目 (TRM4) 的关键区别: - Card 体系:每层节点的描述信息封装为 frozen dataclass(L1Card/L2Card/L3Card), 字段来自 VLM 结构化输出,保证不可变。 - 序列化方式:仅保留 JSON(移除 pickle)。 - 统一嵌入空间:所有 embedding 均来自 text_embed(),无跨模态问题。 """ from __future__ import annotations import base64 import json from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, Any import numpy as np from loguru import logger if TYPE_CHECKING: from collections.abc import Callable # --------------------------------------------------------------------------- # Embedding 序列化辅助函数 # --------------------------------------------------------------------------- def _embed_to_str(arr: np.ndarray | None) -> str | None: """float32 ndarray -> base64 字符串(用于 JSON 序列化)。 参数: arr: float32 数组,形状任意。 返回: base64 编码字符串,或 None(输入为 None 时)。 """ if arr is None: return None return base64.b64encode(arr.astype(np.float32).tobytes()).decode() def _embed_from_str(s: str | None) -> np.ndarray | None: """base64 字符串 -> float32 ndarray(用于 JSON 反序列化)。 参数: s: base64 编码字符串。 返回: float32 数组,或 None(输入为 None/空时)。 """ if s is None or s == "": return None return np.frombuffer(base64.b64decode(s), dtype=np.float32) # --------------------------------------------------------------------------- # Card 数据结构(frozen,来自 VLM 结构化输出) # --------------------------------------------------------------------------- @dataclass(frozen=True) class L3Card: """L3 帧级语义卡片(不可变)。 封装 VLM 对单帧的结构化描述输出。 属性: frame_summary: 帧内容摘要。 visible_entities: 可见实体列表。 ongoing_actions: 正在进行的动作列表。 visible_text: 画面中可见的文字列表。 spatial_layout: 空间布局描述。 visual_attributes: 视觉属性字典(如光照、色调等)。 """ frame_summary: str visible_entities: list[str] ongoing_actions: list[str] visible_text: list[str] spatial_layout: str visual_attributes: dict[str, Any] @dataclass(frozen=True) class L2Card: """L2 事件级语义卡片(不可变)。 封装 VLM 对一个事件片段的结构化描述输出。 属性: event_description: 事件描述。 entities: 参与实体列表。 actions: 动作列表。 action_subjects: 动作主体列表。 visible_text: 片段中可见的文字列表。 spatial_relations: 空间关系描述。 state_changes: 状态变化描述(可选)。 """ event_description: str entities: list[str] actions: list[str] action_subjects: list[str] visible_text: list[str] spatial_relations: str state_changes: str | None @dataclass(frozen=True) class L1Card: """L1 场景级语义卡片(不可变)。 封装 VLM 对一个完整场景的结构化描述输出。 属性: scene_summary: 场景摘要。 main_setting: 主要场景设定(如"室内"、"户外"等)。 key_entities: 关键实体列表。 main_actions: 主要动作列表。 topic_keywords: 主题关键词列表。 visible_text: 场景中可见的文字列表。 temporal_flow: 时间流描述。 """ scene_summary: str main_setting: str key_entities: list[str] main_actions: list[str] topic_keywords: list[str] visible_text: list[str] temporal_flow: str # --------------------------------------------------------------------------- # 元数据 # --------------------------------------------------------------------------- @dataclass class IndexMeta: """树索引元数据。 属性: source_path: 原始数据路径(视频文件或文本文件)。 modality: 数据模态,"text" 或 "video"。 embed_model: 嵌入模型名称(建树时为 None,embed_all 后填充)。 embed_dim: 嵌入向量维度(建树时为 None,embed_all 后填充)。 created_at: 创建时间(ISO 格式字符串)。 """ source_path: str modality: str embed_model: str | None = None embed_dim: int | None = None created_at: str = field(default_factory=lambda: datetime.now().isoformat()) # --------------------------------------------------------------------------- # 节点数据结构 # --------------------------------------------------------------------------- @dataclass class L3Node: """L3 帧/细节级节点(叶子层)。 代表最细粒度的语义单元,对应一个具体的帧描述。 属性: id: 节点唯一标识。 card: 帧级语义卡片(VLM 结构化输出)。 embedding: 文本嵌入向量,形状 [D],float32。 timestamp: 对应的时间戳(秒,可选)。 frame_path: 关联的帧图像路径(可选,仅视频模态)。 subtitle: 该帧对应的字幕文本(可选)。 """ id: str card: L3Card embedding: np.ndarray | None = None timestamp: float | None = None frame_path: str | None = None subtitle: str | None = None @property def description(self) -> str: """帧描述文本(取自 card.frame_summary)。""" return self.card.frame_summary @dataclass class L2Node: """L2 片段级语义节点(中间层)。 连接 L1 宏观叙事与 L3 细节描述。 属性: id: 节点唯一标识。 card: 事件级语义卡片(VLM 结构化输出)。 embedding: 文本嵌入向量,形状 [D],float32。 time_range: 时间范围 (start, end)(秒,可选)。 children: 所属的 L3 子节点列表。 """ id: str card: L2Card embedding: np.ndarray | None = None time_range: tuple[float, float] | None = None children: list[L3Node] = field(default_factory=list) @property def description(self) -> str: """事件描述文本(取自 card.event_description)。""" return self.card.event_description @dataclass class L1Node: """L1 全局叙事节点(根层)。 代表最粗粒度的语义单元,包含宏观场景摘要。 属性: id: 节点唯一标识。 card: 场景级语义卡片(VLM 结构化输出)。 embedding: 文本嵌入向量,形状 [D],float32。 time_range: 时间范围 (start, end)(秒,可选)。 children: 所属的 L2 子节点列表。 """ id: str card: L1Card embedding: np.ndarray | None = None time_range: tuple[float, float] | None = None children: list[L2Node] = field(default_factory=list) @property def summary(self) -> str: """场景摘要文本(取自 card.scene_summary)。""" return self.card.scene_summary # ------------------------------------------------------------------ # JSON 辅助方法(单个 L1 段的轻量序列化) # ------------------------------------------------------------------ def to_dict(self, include_embedding: bool = False) -> dict[str, Any]: """将当前 L1 节点(及其全部 L2/L3 子树)序列化为纯 dict。 参数: include_embedding: 若 True,将 embedding 向量序列化为 base64 字符串。 返回: 包含 id/card/time_range/children 的字典,可选包含 embedding。 """ def l3_to_dict(n: L3Node) -> dict[str, Any]: d: dict[str, Any] = { "id": n.id, "card": { "frame_summary": n.card.frame_summary, "visible_entities": n.card.visible_entities, "ongoing_actions": n.card.ongoing_actions, "visible_text": n.card.visible_text, "spatial_layout": n.card.spatial_layout, "visual_attributes": n.card.visual_attributes, }, "timestamp": n.timestamp, "frame_path": n.frame_path, "subtitle": n.subtitle, } if include_embedding: d["embedding"] = _embed_to_str(n.embedding) return d def l2_to_dict(n: L2Node) -> dict[str, Any]: d: dict[str, Any] = { "id": n.id, "card": { "event_description": n.card.event_description, "entities": n.card.entities, "actions": n.card.actions, "action_subjects": n.card.action_subjects, "visible_text": n.card.visible_text, "spatial_relations": n.card.spatial_relations, "state_changes": n.card.state_changes, }, "time_range": list(n.time_range) if n.time_range else None, "children": [l3_to_dict(c) for c in n.children], } if include_embedding: d["embedding"] = _embed_to_str(n.embedding) return d d: dict[str, Any] = { "id": self.id, "card": { "scene_summary": self.card.scene_summary, "main_setting": self.card.main_setting, "key_entities": self.card.key_entities, "main_actions": self.card.main_actions, "topic_keywords": self.card.topic_keywords, "visible_text": self.card.visible_text, "temporal_flow": self.card.temporal_flow, }, "time_range": list(self.time_range) if self.time_range else None, "children": [l2_to_dict(c) for c in self.children], } if include_embedding: d["embedding"] = _embed_to_str(self.embedding) return d @staticmethod def from_dict(d: dict[str, Any]) -> L1Node: """从 dict 反序列化单个 L1 节点(支持 embedding 恢复)。 参数: d: to_dict() 输出的字典,可包含 embedding 字段。 返回: L1Node 实例(embedding 自动从 base64 恢复,若无则为 None)。 """ l2_nodes: list[L2Node] = [] for l2d in d.get("children", []): l3_nodes: list[L3Node] = [] for l3d in l2d.get("children", []): l3_card = L3Card( frame_summary=l3d["card"]["frame_summary"], visible_entities=l3d["card"]["visible_entities"], ongoing_actions=l3d["card"]["ongoing_actions"], visible_text=l3d["card"]["visible_text"], spatial_layout=l3d["card"]["spatial_layout"], visual_attributes=l3d["card"]["visual_attributes"], ) l3_nodes.append( L3Node( id=l3d["id"], card=l3_card, embedding=_embed_from_str(l3d.get("embedding")), timestamp=l3d.get("timestamp"), frame_path=l3d.get("frame_path"), subtitle=l3d.get("subtitle"), ) ) l2_card = L2Card( event_description=l2d["card"]["event_description"], entities=l2d["card"]["entities"], actions=l2d["card"]["actions"], action_subjects=l2d["card"]["action_subjects"], visible_text=l2d["card"]["visible_text"], spatial_relations=l2d["card"]["spatial_relations"], state_changes=l2d["card"]["state_changes"], ) tr2 = l2d.get("time_range") l2_nodes.append( L2Node( id=l2d["id"], card=l2_card, embedding=_embed_from_str(l2d.get("embedding")), time_range=tuple(tr2) if tr2 else None, children=l3_nodes, ) ) l1_card = L1Card( scene_summary=d["card"]["scene_summary"], main_setting=d["card"]["main_setting"], key_entities=d["card"]["key_entities"], main_actions=d["card"]["main_actions"], topic_keywords=d["card"]["topic_keywords"], visible_text=d["card"]["visible_text"], temporal_flow=d["card"]["temporal_flow"], ) tr1 = d.get("time_range") return L1Node( id=d["id"], card=l1_card, embedding=_embed_from_str(d.get("embedding")), time_range=tuple(tr1) if tr1 else None, children=l2_nodes, ) # --------------------------------------------------------------------------- # 树索引容器 # --------------------------------------------------------------------------- @dataclass class TreeIndex: """三层树索引容器。 组织和管理三层节点结构,提供嵌入矩阵提取、节点访问、 以及 JSON 序列化/反序列化接口。 典型工作流:: # 1. 构建索引 index = TreeIndex(metadata=meta, roots=[l1_node_1, l1_node_2]) # 2. 批量 embed(首次检索前) index.embed_all(embed_fn, "model-name", 768) # 3. 提取嵌入矩阵(用于检索) M_L1 = index.l1_embeddings() M_L2 = index.l2_embeddings_of(l1_idx=0) M_L3 = index.l3_embeddings_of(0, 1) # 4. 序列化 index.save_json("cache/my_index.json") loaded = TreeIndex.load_json("cache/my_index.json") 属性: metadata: 索引元数据。 roots: L1 节点列表。 """ metadata: IndexMeta roots: list[L1Node] = field(default_factory=list) # ------------------------------------------------------------------ # # 嵌入状态检查 # ------------------------------------------------------------------ # @property def is_embedded(self) -> bool: """检查所有节点是否已填充嵌入向量。 返回: True 表示所有 L1/L2/L3 节点的 embedding 均非 None; False 表示尚未 embed。 """ for l1 in self.roots: if l1.embedding is None: return False for l2 in l1.children: if l2.embedding is None: return False for l3 in l2.children: if l3.embedding is None: return False return True # ------------------------------------------------------------------ # # 批量嵌入 # ------------------------------------------------------------------ # def embed_all( self, embed_fn: Callable[[str | list[str]], np.ndarray], model_name: str, embed_dim: int, ) -> None: """对所有节点批量执行 embedding,更新 metadata。 建树阶段不调用此方法(embedding=None)。 首次检索前由 Pipeline 调用,结果缓存在节点上。 参数: embed_fn: EmbeddingModel.embed 方法,接受 str 或 List[str], 返回 [N, D] ndarray。 model_name: 嵌入模型名称,写入 metadata。 embed_dim: 嵌入维度,写入 metadata。 实现细节: - L3 节点按 L2 分组批量 embed(一次调用),减少 API 开销。 - L1/L2 各单独 embed(数量少,不值得合并)。 - 仅对 embedding 为 None 的节点执行(支持增量更新)。 """ assert len(self.roots) > 0, "embed_all: 树为空,无节点可 embed" for l1 in self.roots: if l1.embedding is None: l1.embedding = embed_fn(l1.summary)[0].astype(np.float32) for l2 in l1.children: self._embed_l2_subtree(l2, embed_fn) self.metadata.embed_model = model_name self.metadata.embed_dim = embed_dim logger.info( "embed_all 完成", model=model_name, embed_dim=embed_dim, ) def _embed_l2_subtree( self, l2: L2Node, embed_fn: Callable[[str | list[str]], np.ndarray], ) -> None: """对单个 L2 节点及其 L3 子节点执行 embedding(仅处理 embedding 为 None 的节点)。 参数: l2: 待 embed 的 L2 节点。 embed_fn: EmbeddingModel.embed 方法,接受 str 或 List[str], 返回 [N, D] ndarray。 """ if l2.embedding is None: l2.embedding = embed_fn(l2.description)[0].astype(np.float32) # L3 批量 embed need_embed = [l3 for l3 in l2.children if l3.embedding is None] if need_embed: texts = [l3.description for l3 in need_embed] embs = embed_fn(texts).astype(np.float32) # [N, D] for l3, emb in zip(need_embed, embs, strict=True): l3.embedding = emb # ------------------------------------------------------------------ # # 嵌入矩阵提取 # ------------------------------------------------------------------ # def l1_embeddings(self) -> np.ndarray: """返回所有 L1 节点的嵌入矩阵。 返回: 形状 [N1, D] 的 float32 矩阵。空树返回 [0, D]。 异常: AssertionError: 节点 embedding 尚未计算(请先调用 embed_all)。 """ assert self.is_embedded, "L1 embedding 尚未计算,请先调用 tree.embed_all()" if not self.roots: return np.zeros((0, self.metadata.embed_dim), dtype=np.float32) return np.stack([r.embedding for r in self.roots], axis=0).astype(np.float32) def l2_embeddings_of(self, l1_idx: int) -> np.ndarray: """返回指定 L1 节点下所有 L2 子节点的嵌入矩阵。 参数: l1_idx: L1 节点索引。 返回: 形状 [N2, D] 的 float32 矩阵。 异常: IndexError: l1_idx 越界。 AssertionError: embedding 尚未计算。 """ assert self.is_embedded, "L2 embedding 尚未计算,请先调用 tree.embed_all()" if not (0 <= l1_idx < len(self.roots)): raise IndexError(f"l1_idx={l1_idx} 越界,L1 节点数={len(self.roots)}") children = self.roots[l1_idx].children if not children: return np.zeros((0, self.metadata.embed_dim), dtype=np.float32) return np.stack([c.embedding for c in children], axis=0).astype(np.float32) def l3_embeddings_of(self, l1_idx: int, l2_idx: int) -> np.ndarray: """返回指定 L2 节点下所有 L3 子节点的嵌入矩阵。 参数: l1_idx: L1 节点索引。 l2_idx: L2 节点索引(相对于 L1)。 返回: 形状 [N3, D] 的 float32 矩阵。 异常: IndexError: 索引越界。 AssertionError: embedding 尚未计算。 """ assert self.is_embedded, "L3 embedding 尚未计算,请先调用 tree.embed_all()" if not (0 <= l1_idx < len(self.roots)): raise IndexError(f"l1_idx={l1_idx} 越界,L1 节点数={len(self.roots)}") l2_children = self.roots[l1_idx].children if not (0 <= l2_idx < len(l2_children)): raise IndexError(f"l2_idx={l2_idx} 越界,L2 节点数={len(l2_children)}") l3_children = l2_children[l2_idx].children if not l3_children: return np.zeros((0, self.metadata.embed_dim), dtype=np.float32) return np.stack([c.embedding for c in l3_children], axis=0).astype(np.float32) # ------------------------------------------------------------------ # # 节点访问 # ------------------------------------------------------------------ # def get_node(self, l1: int, l2: int, l3: int) -> L3Node: """按三级路径索引获取 L3 节点。 参数: l1: L1 节点索引。 l2: L2 节点索引。 l3: L3 节点索引。 返回: 目标 L3Node。 异常: IndexError: 任意层级索引越界。 """ if l1 < 0 or l1 >= len(self.roots): raise IndexError(f"l1={l1} 越界,L1 节点数={len(self.roots)}") l2_children = self.roots[l1].children if l2 < 0 or l2 >= len(l2_children): raise IndexError(f"l2={l2} 越界,L2 节点数={len(l2_children)}") l3_children = l2_children[l2].children if l3 < 0 or l3 >= len(l3_children): raise IndexError(f"l3={l3} 越界,L3 节点数={len(l3_children)}") return l3_children[l3] # ------------------------------------------------------------------ # # JSON 序列化 # ------------------------------------------------------------------ # def to_dict(self, include_embedding: bool = False) -> dict[str, Any]: """将树索引序列化为纯 Python dict。 参数: include_embedding: 若 True,将所有节点的 embedding 向量序列化为 base64。 返回: 可直接 json.dump 的字典,结构为 {metadata, roots[...]}。 """ metadata_dict: dict[str, Any] = { "source_path": self.metadata.source_path, "modality": self.metadata.modality, "created_at": self.metadata.created_at, } if include_embedding: metadata_dict["embed_model"] = self.metadata.embed_model metadata_dict["embed_dim"] = self.metadata.embed_dim return { "metadata": metadata_dict, "roots": [r.to_dict(include_embedding=include_embedding) for r in self.roots], } @classmethod def from_dict(cls, d: dict[str, Any]) -> TreeIndex: """从 dict 反序列化为 TreeIndex(支持 embedding 恢复)。 参数: d: to_dict() 的输出或等价结构,可包含 embedding 字段。 返回: TreeIndex 实例。 异常: ValueError: 存在重复的节点 ID。 """ meta = IndexMeta( source_path=d["metadata"]["source_path"], modality=d["metadata"]["modality"], embed_model=d["metadata"].get("embed_model"), embed_dim=d["metadata"].get("embed_dim"), created_at=d["metadata"].get("created_at", datetime.now().isoformat()), ) roots: list[L1Node] = [] for r in d["roots"]: roots.append(L1Node.from_dict(r)) return cls(metadata=meta, roots=roots) def _validate_id_uniqueness(self) -> None: """校验树中所有节点 ID 的唯一性。 异常: ValueError: 存在重复的节点 ID。 """ seen: set[str] = set() for l1 in self.roots: if l1.id in seen: raise ValueError(f"重复的节点 ID: {l1.id}") seen.add(l1.id) for l2 in l1.children: if l2.id in seen: raise ValueError(f"重复的节点 ID: {l2.id}") seen.add(l2.id) for l3 in l2.children: if l3.id in seen: raise ValueError(f"重复的节点 ID: {l3.id}") seen.add(l3.id) def save_json(self, path: str, include_embedding: bool = False) -> None: """将树索引以 JSON 格式保存到磁盘。 参数: path: 保存文件路径(推荐 .json 后缀)。 include_embedding: 若 True,将所有节点的 embedding 向量保存到 JSON。 """ with open(path, "w", encoding="utf-8") as f: json.dump( self.to_dict(include_embedding=include_embedding), f, ensure_ascii=False, indent=2, ) logger.info( "树索引(JSON)已保存至 {}", path, n_l1=len(self.roots), include_embedding=include_embedding, ) @classmethod def load_json(cls, path: str) -> TreeIndex: """从 JSON 文件加载树索引(自动检测并恢复 embedding)。 参数: path: JSON 文件路径。 返回: TreeIndex 实例。若 JSON 中包含 embedding 字段,自动反序列化填充; 否则 embedding=None(向后兼容旧格式)。 异常: FileNotFoundError: 文件不存在。 ValueError: 存在重复的节点 ID。 """ with open(path, encoding="utf-8") as f: d = json.load(f) obj = cls.from_dict(d) obj._validate_id_uniqueness() logger.info( "树索引(JSON)已从 {} 加载", path, n_l1=len(obj.roots), is_embedded=obj.is_embedded, ) return obj # --------------------------------------------------------------------------- # 单 L1 段的轻量序列化(用于断点续跑) # --------------------------------------------------------------------------- def save_l1_json(path: str, l1_node: L1Node) -> None: """将单个 L1 节点(及其子树)以 JSON 形式保存到磁盘。 参数: path: 目标文件路径。 l1_node: 待序列化的 L1 节点。 """ with open(path, "w", encoding="utf-8") as f: json.dump(l1_node.to_dict(), f, ensure_ascii=False, indent=2) logger.info("L1 中间结果已保存", path=path, l1_id=l1_node.id) def load_l1_json(path: str) -> L1Node: """从 JSON 文件加载单个 L1 节点(embedding=None)。 参数: path: JSON 文件路径。 返回: L1Node 实例。 """ with open(path, encoding="utf-8") as f: data = json.load(f) node = L1Node.from_dict(data) logger.info("L1 中间结果已加载", path=path, l1_id=node.id) return node