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
This commit is contained in:
2026-07-06 20:59:03 -04:00
parent 0616d16956
commit 6bdb802f01
98 changed files with 19321 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
"""
Video-Tree-TRM 核心包
=====================
结合 TRM 多层对话探索能力与 PageIndex 树状检索能力的新型 Video RAG。
"""
from video_tree_trm.config import Config
from video_tree_trm.embeddings import EmbeddingModel
from video_tree_trm.llm_client import LLMClient
from video_tree_trm.text_tree_builder import TextTreeBuilder
from video_tree_trm.tree_index import (
IndexMeta,
L1Node,
L2Node,
L3Node,
TreeIndex,
)
__all__ = [
"Config",
"EmbeddingModel",
"LLMClient",
"TextTreeBuilder",
"IndexMeta",
"L1Node",
"L2Node",
"L3Node",
"TreeIndex",
]
+440
View File
@@ -0,0 +1,440 @@
"""
配置管理模块
============
定义所有超参数的 dataclass 类型(无默认值)+ 多源加载。
三层优先级: CLI args > .env > YAML,统一归口到 Config dataclass。
使用方式::
from video_tree_trm.config import Config
cfg = Config.load("config/default.yaml")
cfg = Config.load("config/default.yaml", cli_args={"retriever.num_heads": "8"})
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import yaml
from dotenv import dotenv_values
from utils.logger_system import ensure, log_msg
# ---------------------------------------------------------------------------
# 子配置 Dataclass(全部无默认值,YAML 必须写全)
# ---------------------------------------------------------------------------
@dataclass
class TreeConfig:
"""树索引构建参数。
属性:
max_paragraphs_per_l2: 每个 L2 节点包含的最大段落数(文本模式)。
l1_segment_duration: L1 段时长,秒(视频模式)。
l2_clip_duration: L2 clip 时长,秒(视频模式)。
l3_fps: L3 帧提取频率(视频模式)。
l2_representative_frames: L2 VLM 描述用的代表帧数。
cache_dir: TreeIndex 缓存目录。
"""
max_paragraphs_per_l2: int
l1_segment_duration: float
l2_clip_duration: float
l3_fps: float
l2_representative_frames: int
cache_dir: str
concurrency: int
@dataclass
class EmbedConfig:
"""嵌入模型参数。
属性:
backend: 嵌入后端类型,"local"sentence-transformers)或 "remote"OpenAI 兼容 API)。
model_name: 本地模式为 HuggingFace 模型名,远程模式为 API 模型名。
embed_dim: 嵌入维度 D。
device: 推理设备,"cuda""cpu"(仅本地模式使用)。
api_key: 远程模式 API 密钥,从 .env 加载。本地模式为空串。
api_url: 远程模式 API 端点。本地模式为空串。
"""
backend: str
model_name: str
embed_dim: int
device: str
api_key: str
api_url: str
@dataclass
class LLMConfig:
"""大语言模型参数。
属性:
backend: 后端类型,"qwen" | "openai" | "ollama"
api_key: API 密钥,从 .env 加载。
model: 模型名称。
api_url: API 端点 URL。
max_tokens: 最大生成 token 数。
temperature: 采样温度。
"""
backend: str
api_key: str
model: str
api_url: str
max_tokens: int
temperature: float
@dataclass
class VLMConfig:
"""视觉语言模型参数。
属性:
backend: 后端类型,"qwen" | "openai" | "ollama"
api_key: API 密钥,从 .env 加载。
model: 模型名称。
api_url: API 端点 URL。
max_tokens: 最大生成 token 数。
temperature: 采样温度。
"""
backend: str
api_key: str
model: str
api_url: str
max_tokens: int
temperature: float
@dataclass
class RetrieverConfig:
"""TRM 检索器参数。
属性:
embed_dim: 嵌入维度,须与 EmbedConfig.embed_dim 一致。
num_heads: Cross-Attention 头数。
L_layers: ReasoningModule 层数。
L_cycles: 每级推理迭代次数。
max_rounds: ACT 最大遍历轮次。
ffn_expansion: SwiGLU 扩展比。
checkpoint: 训练好的模型权重路径,推理时必填。
"""
embed_dim: int
num_heads: int
L_layers: int
L_cycles: int
max_rounds: int
ffn_expansion: float
checkpoint: Optional[str]
# 多路径检索配置 (Top-k)
k_l1: int = 1
k_l2: int = 1
k_l3: int = 1
max_paths: int = 5
@dataclass
class TrainConfig:
"""训练参数。
属性:
lr: 学习率。
weight_decay: 权重衰减。
batch_size: 批大小。
max_epochs_phase1: Phase 1 导航训练轮数。
max_epochs_phase2: Phase 2 ACT 训练轮数。
nav_loss_weight: 导航损失权重。
act_loss_weight: ACT 损失权重。
act_lambda_step: ACT 步数惩罚系数。
act_gamma: ACT 折扣因子。
eval_interval: 每 N epoch 评估一次。
save_dir: 模型权重保存目录。
dataset: 数据集名称,"longbench" | "narrativeqa" | "videomme"
dataset_path: 数据集路径。
"""
lr: float
weight_decay: float
batch_size: int
max_epochs_phase1: int
max_epochs_phase2: int
nav_loss_weight: float
act_loss_weight: float
margin_loss_weight: float
act_lambda_step: float
act_gamma: float
eval_interval: int
save_dir: str
dataset: str
dataset_path: str
@dataclass
class HierRetrieverConfig:
"""Hierarchical Cross-Encoder 检索器参数。
属性:
backbone_model: 预训练语言模型名称。
num_heads: Cross-Attention 头数。
hidden_dim: 隐藏层维度。
dropout: Dropout 概率。
use_query_dep_weights: 是否使用 Query-dependent 层级权重。
level_weight_type: 层级权重类型,"fixed" | "query_dependent" | "hybrid"
# Stage 1 参数
stage1_epochs: Stage 1 训练轮数。
stage1_lr: Stage 1 学习率。
stage1_batch_size: Stage 1 批大小。
stage1_num_negatives: Stage 1 每样本的负例数量。
stage1_temperature: Stage 1 温度参数。
# Stage 2 参数
stage2_epochs: Stage 2 训练轮数。
stage2_lr: Stage 2 学习率。
stage2_batch_size: Stage 2 批大小。
stage2_num_negatives: Stage 2 每样本的负例数量。
stage2_temperature: Stage 2 温度参数。
stage2_hard_neg_update_freq: Stage 2 硬负例更新频率。
stage2_hier_loss_weight: Stage 2 层级一致性损失权重。
# Stage 3 参数
stage3_enabled: 是否启用 Stage 3。
stage3_epochs: Stage 3 训练轮数。
stage3_lr: Stage 3 学习率。
# 推理参数
coarse_top_k: 粗排候选数量。
fine_top_k: 精排返回数量。
use_bm25: 是否使用 BM25 辅助粗排。
"""
# 模型参数
backbone_model: str
num_heads: int
hidden_dim: int
dropout: float
use_query_dep_weights: bool
level_weight_type: str
# Stage 1
stage1_epochs: int
stage1_lr: float
stage1_batch_size: int
stage1_num_negatives: int
stage1_temperature: float
# Stage 2
stage2_epochs: int
stage2_lr: float
stage2_batch_size: int
stage2_num_negatives: int
stage2_temperature: float
stage2_hard_neg_update_freq: int
stage2_hier_loss_weight: float
# Stage 3
stage3_enabled: bool
stage3_epochs: int
stage3_lr: float
# 推理
coarse_top_k: int
fine_top_k: int
use_bm25: bool
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
_SECTION_TO_CLASS: dict[str, type] = {
"tree": TreeConfig,
"embed": EmbedConfig,
"llm": LLMConfig,
"vlm": VLMConfig,
"retriever": RetrieverConfig,
"train": TrainConfig,
}
def _deep_merge(base: dict, override: dict) -> dict:
"""递归合并字典,override 优先覆盖 base。
参数:
base: 基础字典。
override: 覆盖字典。
返回:
合并后的新字典。
"""
merged = base.copy()
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def _apply_dotpath(d: dict, key: str, value: Any) -> None:
"""通过点路径设置嵌套字典的值。
支持 "retriever.num_heads" 风格的路径,自动拆分并逐级写入。
参数:
d: 目标字典。
key: 点分隔的路径,如 "retriever.num_heads"
value: 要设置的值。
"""
parts = key.split(".")
current = d
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
def _coerce_value(raw: str, target_type: type) -> Any:
"""将 CLI 字符串值转换为目标类型。
参数:
raw: 原始字符串值。
target_type: 目标 Python 类型。
返回:
转换后的值。
"""
if target_type is bool:
return raw.lower() in ("true", "1", "yes")
if target_type is type(None):
return None if raw.lower() in ("none", "null", "") else raw
return target_type(raw)
# ---------------------------------------------------------------------------
# 顶层配置
# ---------------------------------------------------------------------------
@dataclass
class Config:
"""全局配置容器。
统一管理所有子模块配置,通过 ``Config.load()`` 加载。
属性:
tree: 树索引构建参数。
embed: 嵌入模型参数。
llm: 大语言模型参数。
vlm: 视觉语言模型参数。
retriever: TRM 检索器参数。
train: 训练参数。
hier_retriever: Hierarchical Cross-Encoder 检索器参数。
"""
tree: TreeConfig
embed: EmbedConfig
llm: LLMConfig
vlm: VLMConfig
retriever: RetrieverConfig
train: TrainConfig
hier_retriever: Optional[HierRetrieverConfig] = None
@classmethod
def load(
cls,
yaml_path: str,
cli_args: Optional[dict[str, str]] = None,
env_path: Optional[str] = None,
) -> "Config":
"""三层合并加载配置。
优先级: CLI args > .env > YAML。
参数:
yaml_path: YAML 配置文件路径。
cli_args: CLI 覆盖参数,键为点路径(如 "retriever.num_heads"),值为字符串。
env_path: .env 文件路径,默认为项目根目录的 .env。
返回:
完整的 Config 实例。
异常:
FileNotFoundError: YAML 文件不存在。
TypeError: YAML 中缺少必需字段。
"""
# Phase 1: 读取 YAML
yaml_file = Path(yaml_path)
if not yaml_file.exists():
raise FileNotFoundError(f"配置文件不存在: {yaml_path}")
with open(yaml_file, encoding="utf-8") as f:
base_dict: dict = yaml.safe_load(f)
# Phase 2: 读取 .env 覆盖敏感字段
if env_path is None:
env_file = Path(yaml_path).parent.parent / ".env"
else:
env_file = Path(env_path)
if env_file.exists():
env_vars = dotenv_values(str(env_file))
env_overrides: dict[str, dict[str, str]] = {}
# .env 变量名 → (配置节, 字段名) 的映射
_ENV_MAP: dict[str, tuple[str, str]] = {
"LLM_API_KEY": ("llm", "api_key"),
"LLM_MODEL": ("llm", "model"),
"LLM_API_URL": ("llm", "api_url"),
"VLM_API_KEY": ("vlm", "api_key"),
"VLM_MODEL": ("vlm", "model"),
"VLM_API_URL": ("vlm", "api_url"),
"EMBED_BACKEND": ("embed", "backend"),
"EMBED_MODEL": ("embed", "model_name"),
"EMBED_API_KEY": ("embed", "api_key"),
"EMBED_API_URL": ("embed", "api_url"),
}
for env_name, (section, field) in _ENV_MAP.items():
if env_vars.get(env_name):
env_overrides.setdefault(section, {})[field] = env_vars[env_name]
base_dict = _deep_merge(base_dict, env_overrides)
# Phase 3: CLI args 覆盖
if cli_args:
for dotpath, value in cli_args.items():
_apply_dotpath(base_dict, dotpath, value)
# Phase 4: 构造 dataclass(缺字段自动抛 TypeError
sections = {}
for section_name, dc_class in _SECTION_TO_CLASS.items():
section_data = base_dict.get(section_name, {})
if not isinstance(section_data, dict):
# 对于 Optional 的 section,跳过
if section_name == "hier_retriever":
continue
raise TypeError(
f"配置节 '{section_name}' 必须是字典,实际为 {type(section_data)}"
)
sections[section_name] = dc_class(**section_data)
config = cls(**sections)
# 校验: embed_dim 一致性
ensure(
config.embed.embed_dim == config.retriever.embed_dim,
f"embed.embed_dim ({config.embed.embed_dim}) 与 "
f"retriever.embed_dim ({config.retriever.embed_dim}) 不一致",
)
log_msg("INFO", "配置加载完成", yaml=yaml_path)
return config
+190
View File
@@ -0,0 +1,190 @@
"""
嵌入服务模块
============
封装文本嵌入器,支持本地 sentence-transformers 和远程 OpenAI 兼容 API 两种后端。
提供统一的 ``embed()`` / ``embed_tensor()`` 接口,冻结不训练。
使用方式::
from video_tree_trm.embeddings import EmbeddingModel
from video_tree_trm.config import Config
cfg = Config.load("config/default.yaml")
model = EmbeddingModel(cfg.embed)
vecs = model.embed(["你好世界"]) # ndarray [1, D]
tens = model.embed_tensor(["你好"]) # Tensor [1, D]
"""
from __future__ import annotations
from typing import List, Union
import numpy as np
import torch
from numpy import ndarray
from torch import Tensor
from utils.logger_system import ensure, log_msg
from video_tree_trm.config import EmbedConfig
class EmbeddingModel:
"""文本嵌入器封装(冻结),支持本地和远程双后端。
本地模式: 使用 sentence-transformers 加载 HuggingFace 模型,本地推理。
远程模式: 调用 OpenAI 兼容 API(如 GPUStack 上的 qwen3-embedding)。
属性:
dim: 嵌入维度 D。
"""
def __init__(self, config: EmbedConfig) -> None:
"""初始化嵌入模型。
参数:
config: 嵌入配置,包含 backend、model_name、embed_dim 等。
异常:
ValueError: backend 不是 "local""remote"
ValueError: 远程模式缺少 api_key 或 api_url。
"""
ensure(
config.backend in ("local", "remote"),
f"embed.backend 必须为 'local''remote',实际为 '{config.backend}'",
)
self._backend = config.backend
self._dim = config.embed_dim
if self._backend == "local":
self._init_local(config)
else:
self._init_remote(config)
log_msg(
"INFO", "嵌入模型初始化完成", backend=self._backend, model=config.model_name
)
# ------------------------------------------------------------------
# 初始化
# ------------------------------------------------------------------
def _init_local(self, config: EmbedConfig) -> None:
"""初始化本地 sentence-transformers 模型。
参数:
config: 嵌入配置。
"""
from sentence_transformers import SentenceTransformer
self._model = SentenceTransformer(config.model_name, device=config.device)
self._model.eval()
# 冻结所有参数
for param in self._model.parameters():
param.requires_grad = False
actual_dim = self._model.get_sentence_embedding_dimension()
ensure(
actual_dim == self._dim,
f"模型实际维度 ({actual_dim}) 与配置 embed_dim ({self._dim}) 不一致",
)
def _init_remote(self, config: EmbedConfig) -> None:
"""初始化远程 OpenAI 兼容 API 客户端。
参数:
config: 嵌入配置。
"""
ensure(bool(config.api_key), "远程模式必须提供 embed.api_key")
ensure(bool(config.api_url), "远程模式必须提供 embed.api_url")
from openai import OpenAI
self._client = OpenAI(base_url=config.api_url, api_key=config.api_key)
self._model_name = config.model_name
# ------------------------------------------------------------------
# 公共接口
# ------------------------------------------------------------------
@property
def dim(self) -> int:
"""嵌入维度 D。"""
return self._dim
def embed(self, texts: Union[str, List[str]]) -> ndarray:
"""文本 → 嵌入向量(L2 归一化)。
参数:
texts: 单条文本或文本列表。
返回:
[N, D] ndarray,每行 L2 范数为 1.0。单条文本时 N=1。
"""
if isinstance(texts, str):
texts = [texts]
if self._backend == "local":
return self._embed_local(texts)
return self._embed_remote(texts)
def embed_tensor(self, texts: Union[str, List[str]]) -> Tensor:
"""文本 → 嵌入 Tensor(L2 归一化)。
参数:
texts: 单条文本或文本列表。
返回:
[N, D] torch.Tensorfloat32)。
"""
arr = self.embed(texts)
return torch.from_numpy(arr).float()
# ------------------------------------------------------------------
# 后端实现
# ------------------------------------------------------------------
def _embed_local(self, texts: List[str]) -> ndarray:
"""本地 sentence-transformers 推理。
参数:
texts: 文本列表。
返回:
[N, D] ndarrayL2 归一化。
"""
with torch.no_grad():
embeddings = self._model.encode(
texts,
normalize_embeddings=True,
convert_to_numpy=True,
)
# sentence-transformers encode 返回 ndarray [N, D]
if embeddings.ndim == 1:
embeddings = embeddings.reshape(1, -1)
return embeddings
def _embed_remote(self, texts: List[str]) -> ndarray:
"""远程 OpenAI 兼容 API 调用。
参数:
texts: 文本列表。
返回:
[N, D] ndarrayL2 归一化。
"""
response = self._client.embeddings.create(
model=self._model_name,
input=texts,
)
# 按 index 排序,确保顺序一致
sorted_data = sorted(response.data, key=lambda x: x.index)
embeddings = np.array(
[item.embedding for item in sorted_data], dtype=np.float32
)
# L2 归一化
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms = np.maximum(norms, 1e-12) # 避免除零
embeddings = embeddings / norms
return embeddings
+421
View File
@@ -0,0 +1,421 @@
"""
LLM/VLM 客户端模块
==================
统一封装 LLM(纯文本)和 VLM(多模态)API 调用。
仅支持 OpenAI-compatible 接口,通过配置 api_url + model 适配不同服务商
(如 Qwen DashScope、OpenAI、本地推理服务等)。
提供同步版(chat / chat_with_images)和异步版(chat_async / chat_with_images_async)。
异步版基于 openai.AsyncOpenAI,适配 asyncio 事件循环,零线程阻塞。
使用方式::
from video_tree_trm.llm_client import LLMClient
from video_tree_trm.config import Config
cfg = Config.load("config/default.yaml")
vlm = LLMClient(cfg.vlm)
# 同步
answer = vlm.chat_with_images("图中有什么?", images=["frame.jpg"])
# 异步
import asyncio
answer = asyncio.run(vlm.chat_with_images_async("图中有什么?", images=["frame.jpg"]))
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Optional, Union
import httpx
import openai
from utils.logger_system import log_exception, log_msg
from video_tree_trm.config import LLMConfig, VLMConfig
# 502/503 时的重试参数
_RETRY_STATUS_CODES = {502, 503}
_MAX_RETRIES = 20 # 最多重试次数(约等待 20+ 分钟)
_RETRY_BASE_WAIT = 60 # 首次等待 60 秒
_RETRY_MAX_WAIT = 300 # 单次等待上限 5 分钟
def _call_with_retry(fn, label: str):
"""对 fn() 调用执行指数退避重试(重试 502/503 及超时)。
参数:
fn: 无参调用的函数,返回 API response。
label: 日志标识(如方法名)。
返回:
fn() 的返回值。
异常:
openai.OpenAIError: 超过最大重试次数或非可重试错误时抛出。
"""
wait = _RETRY_BASE_WAIT
for attempt in range(1, _MAX_RETRIES + 1):
try:
return fn()
except openai.APITimeoutError:
log_msg(
"WARNING",
f"{label} 请求超时,等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
time.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
except openai.InternalServerError as exc:
status = getattr(exc, "status_code", None)
if status not in _RETRY_STATUS_CODES:
raise
log_msg(
"WARNING",
f"{label} 遇到 {status},等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
time.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
async def _async_call_with_retry(coro_fn, label: str):
"""异步版指数退避重试,适配 asyncio 事件循环。
参数:
coro_fn: 无参调用的协程工厂函数(每次调用返回新协程)。
label: 日志标识(如方法名)。
返回:
coro_fn() 的返回值。
实现细节:
使用 await asyncio.sleep() 替代 time.sleep(),不阻塞事件循环。
每次重试需重新调用 coro_fn() 构造新协程(协程不可复用)。
"""
wait = _RETRY_BASE_WAIT
for attempt in range(1, _MAX_RETRIES + 1):
try:
return await coro_fn()
except openai.APITimeoutError:
log_msg(
"WARNING",
f"{label} 请求超时,等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
await asyncio.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
except openai.InternalServerError as exc:
status = getattr(exc, "status_code", None)
if status not in _RETRY_STATUS_CODES:
raise
log_msg(
"WARNING",
f"{label} 遇到 {status},等待 {wait}s 后重试",
attempt=attempt,
max_retries=_MAX_RETRIES,
)
await asyncio.sleep(wait)
wait = min(wait * 2, _RETRY_MAX_WAIT)
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
class LLMClient:
"""OpenAI-compatible LLM/VLM 统一客户端。
同时提供同步接口(chat / chat_with_images)和异步接口(chat_async / chat_with_images_async)。
异步接口使用独立的 AsyncOpenAI 实例,零线程阻塞,与 asyncio.Semaphore 配合实现真并发。
属性:
_config: LLMConfig 或 VLMConfig 配置对象。
_client: openai.OpenAI 同步客户端。
_async_client: openai.AsyncOpenAI 异步客户端。
_extra_body: 关闭 Qwen3 thinking 模式的额外参数。
"""
def __init__(self, config: Union[LLMConfig, VLMConfig]) -> None:
"""初始化 LLM/VLM 客户端(同步 + 异步双客户端)。
参数:
config: LLMConfig 或 VLMConfig,包含 api_key、api_url、model 等参数。
异常:
ValueError: api_key 或 api_url 为空时抛出。
"""
if not config.api_key:
raise ValueError(
"LLMClient 初始化失败: config.api_key 不能为空,请在 .env 中设置"
)
if not config.api_url:
raise ValueError(
"LLMClient 初始化失败: config.api_url 不能为空,请在 config/default.yaml 中设置"
)
self._config = config
# 同步客户端(向后兼容)
self._client = openai.OpenAI(
api_key=config.api_key,
base_url=config.api_url,
http_client=httpx.Client(proxy=None),
)
# 异步客户端(asyncio 场景,零阻塞)
self._async_client = openai.AsyncOpenAI(
api_key=config.api_key,
base_url=config.api_url,
http_client=httpx.AsyncClient(proxy=None),
)
# 关闭 Qwen3 thinking 模式(vLLM 正确格式)
self._extra_body: Dict = {"chat_template_kwargs": {"enable_thinking": False}}
log_msg(
"INFO", "LLMClient 初始化完成", model=config.model, api_url=config.api_url
)
# ── 同步接口(向后兼容)─────────────────────────────────────────────────
def chat(self, prompt: str, max_tokens: Optional[int] = None) -> str:
"""纯文本单轮对话(同步)。
参数:
prompt: 用户输入文本。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
"""
messages = self._build_messages(prompt)
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = _call_with_retry(
lambda: self._client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat 调用失败", exc)
raise
def chat_with_images(
self,
prompt: str,
images: List[str],
max_tokens: Optional[int] = None,
) -> str:
"""多模态单轮对话(VLM,同步)。
参数:
prompt: 文本指令。
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
"""
encoded = [self._encode_image(img) for img in images]
messages = self._build_messages(prompt, images=encoded)
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = _call_with_retry(
lambda: self._client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat_with_images",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat_with_images 调用失败", exc)
raise
def batch_chat(
self,
prompts: List[str],
max_tokens: Optional[int] = None,
) -> List[str]:
"""批量纯文本并发对话,保序返回(同步)。
参数:
prompts: 文本输入列表。
max_tokens: 最大生成 token 数。
返回:
与 prompts 等长的生成文本列表,顺序与输入对应。
"""
results: List[str] = [""] * len(prompts)
with ThreadPoolExecutor(max_workers=8) as executor:
future_to_idx = {
executor.submit(self.chat, prompt, max_tokens): idx
for idx, prompt in enumerate(prompts)
}
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
results[idx] = future.result()
return results
# ── 异步接口(asyncio 事件循环,零阻塞)──────────────────────────────────
async def chat_async(self, prompt: str, max_tokens: Optional[int] = None) -> str:
"""纯文本单轮对话(异步,零线程阻塞)。
参数:
prompt: 用户输入文本。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
实现细节:
使用 AsyncOpenAI 客户端,await 期间事件循环可处理其他协程,
配合 asyncio.Semaphore 实现受控并发。
"""
messages = self._build_messages(prompt)
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = await _async_call_with_retry(
lambda: self._async_client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat_async",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat_async 调用失败", exc)
raise
async def chat_with_images_async(
self,
prompt: str,
images: List[str],
max_tokens: Optional[int] = None,
) -> str:
"""多模态单轮对话(VLM,异步,零线程阻塞)。
参数:
prompt: 文本指令。
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
返回:
生成的文本字符串。
实现细节:
图像编码(磁盘读取 + base64)在默认线程池执行器中并行执行,
避免阻塞事件循环;VLM API 调用通过 AsyncOpenAI 零阻塞。
"""
loop = asyncio.get_event_loop()
# 并行编码所有图像(I/O 密集,交给线程池)
encoded: List[str] = await asyncio.gather(
*[loop.run_in_executor(None, self._encode_image, img) for img in images]
)
messages = self._build_messages(prompt, images=list(encoded))
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
try:
response = await _async_call_with_retry(
lambda: self._async_client.chat.completions.create(
model=self._config.model,
messages=messages,
max_tokens=tokens,
temperature=self._config.temperature,
extra_body=self._extra_body,
),
label="LLMClient.chat_with_images_async",
)
return self._strip_thinking(response.choices[0].message.content)
except Exception as exc:
log_exception("LLMClient.chat_with_images_async 调用失败", exc)
raise
# ── 私有辅助方法 ──────────────────────────────────────────────────────────
@staticmethod
def _strip_thinking(content: str) -> str:
"""剥离 Qwen3 thinking 模式生成的 <think>...</think> 块。
参数:
content: VLM/LLM 原始返回文本(可能含 <think> 块)。
返回:
去除 think 块后的纯净文本。
实现细节:
当 API 参数无法完全禁用 thinking 时作为兜底保障。
<think> 块可能跨多行,使用 DOTALL 模式匹配。
"""
cleaned = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
return cleaned.strip()
def _encode_image(self, path_or_b64: str) -> str:
"""将图像转换为 data URI 格式的 base64 字符串。
参数:
path_or_b64: 本地文件路径,或已是 "data:image/...;base64,..." 格式的字符串。
返回:
"data:image/jpeg;base64,<base64数据>" 格式字符串。
异常:
FileNotFoundError: 指定路径文件不存在时抛出。
"""
if "base64," in path_or_b64:
return path_or_b64
if not os.path.exists(path_or_b64):
raise FileNotFoundError(f"图像文件不存在: {path_or_b64}")
with open(path_or_b64, "rb") as f:
raw = f.read()
b64_data = base64.b64encode(raw).decode("utf-8")
ext = os.path.splitext(path_or_b64)[1].lower()
mime = "image/png" if ext == ".png" else "image/jpeg"
return f"data:{mime};base64,{b64_data}"
def _build_messages(
self,
prompt: str,
images: Optional[List[str]] = None,
) -> List[Dict]:
"""拼装 OpenAI-compatible 消息结构。
参数:
prompt: 文本指令。
images: 已编码的 base64 data URI 列表(可为 None)。
返回:
OpenAI messages 格式的列表。
实现细节:
- 无图像:content 为纯字符串。
- 有图像:content 为列表,图像在前,文本在后。
"""
if not images:
return [{"role": "user", "content": prompt}]
content: List[Dict] = [
{"type": "image_url", "image_url": {"url": img}} for img in images
]
content.append({"type": "text", "text": prompt})
return [{"role": "user", "content": content}]
+266
View File
@@ -0,0 +1,266 @@
"""
端到端推理管线
==============
串联 预处理 → 检索 → 生成 的完整推理流程。
提供 ``build_index()`` 和 ``query()`` 两个高层接口。
使用方式::
from video_tree_trm.config import Config
from video_tree_trm.pipeline import Pipeline
cfg = Config.load("config/default.yaml")
pipeline = Pipeline(cfg)
# 构建(或从缓存加载)树索引
tree = pipeline.build_index("data/my_doc.txt", modality="text")
# 问答
answer = pipeline.query("文档的主要结论是什么?", tree)
print(answer)
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
import torch
from utils.logger_system import ensure, log_msg
from video_tree_trm.answer_generator import AnswerGenerator
from video_tree_trm.config import Config
from video_tree_trm.embeddings import EmbeddingModel
from video_tree_trm.llm_client import LLMClient
from video_tree_trm.recursive_retriever import RecursiveRetriever
from video_tree_trm.text_tree_builder import TextTreeBuilder
from video_tree_trm.tree_index import TreeIndex
from video_tree_trm.video_tree_builder import VideoTreeBuilder
class Pipeline:
"""端到端推理管线(预处理 → 检索 → 生成)。
将所有子模块按配置串联,对外暴露两个接口:
- ``build_index()``: 从原始文件构建 TreeIndex,支持磁盘缓存。
- ``query()``: 对已有 TreeIndex 执行问答,返回生成答案字符串。
属性:
config: 全局配置对象。
embed_model: 文本嵌入模型(冻结)。
llm: 文本大语言模型客户端。
vlm: 视觉语言模型客户端。
retriever: TRM 递归检索器(eval 模式)。
generator: 答案生成器。
"""
def __init__(self, config: Config) -> None:
"""初始化端到端推理管线。
参数:
config: 通过 ``Config.load()`` 加载的全局配置对象。
实现细节:
- 若 ``config.retriever.checkpoint`` 非 None,加载预训练权重。
- 检索器始终切换到 eval 模式(关闭 Dropout 等训练行为)。
"""
self.config = config
# Phase 1: 初始化各子模块(embed_model 懒加载,仅 query/embed 时触发)
self._embed_model: Optional[EmbeddingModel] = None
self.llm = LLMClient(config.llm)
self.vlm = LLMClient(config.vlm)
self.retriever = RecursiveRetriever(config.retriever)
# Phase 2: 可选加载检查点
if config.retriever.checkpoint:
ensure(
os.path.isfile(config.retriever.checkpoint),
f"检查点文件不存在: {config.retriever.checkpoint}",
)
state_dict = torch.load(config.retriever.checkpoint, map_location="cpu")
self.retriever.load_state_dict(state_dict)
log_msg(
"INFO",
"检索器权重已加载",
checkpoint=config.retriever.checkpoint,
)
self.retriever.eval()
self.generator = AnswerGenerator(self.llm, self.vlm)
log_msg(
"INFO",
"Pipeline 初始化完成",
modality_embed=config.embed.model_name,
has_checkpoint=bool(config.retriever.checkpoint),
)
@property
def embed_model(self) -> EmbeddingModel:
"""懒加载 EmbeddingModel,仅在首次访问时初始化(index 阶段不触发)。"""
if self._embed_model is None:
log_msg("INFO", "懒加载 EmbeddingModel", model=self.config.embed.model_name)
self._embed_model = EmbeddingModel(self.config.embed)
return self._embed_model
def build_index(self, source_path: str, modality: str) -> TreeIndex:
"""构建并缓存 TreeIndexJSON 格式,含 embedding)。
参数:
source_path: 原始文件路径(文本文件或视频文件)。
modality: 模态类型,"text""video"
返回:
构建完成的 TreeIndex 对象(已 embed)。
实现细节:
- 缓存路径: ``{cache_dir}/{stem}_{modality}.json``。
- 缓存命中时直接反序列化返回(自动恢复 embedding 若有)。
- 缓存未命中时调用 VLM 生成描述文本,执行 embedding,保存为 JSON。
"""
ensure(
modality in ("text", "video"),
f"modality 须为 'text''video',实际={modality}",
)
# Phase 1: 缓存路径计算
stem = Path(source_path).stem
cache_dir = Path(self.config.tree.cache_dir)
cache_dir.mkdir(parents=True, exist_ok=True)
cache_path = str(cache_dir / f"{stem}_{modality}.json")
if os.path.isfile(cache_path):
log_msg("INFO", "缓存命中,直接加载 TreeIndex", cache_path=cache_path)
tree = TreeIndex.load_json(cache_path)
# 若缓存中已有 embedding,直接返回;否则按需 embed
if tree.is_embedded:
return tree
log_msg("INFO", "缓存中无 embedding,开始执行 embed_all")
self._embed_tree(tree, cache_path=cache_path)
return tree
# Phase 2: 构建树索引(纯 VLM 文字描述)
log_msg(
"INFO",
"缓存未命中,开始构建 TreeIndex",
source_path=source_path,
modality=modality,
)
if modality == "text":
with open(source_path, encoding="utf-8") as f:
text = f.read()
builder = TextTreeBuilder(self.llm, self.config.tree)
tree = builder.build(text, source_path)
else:
builder = VideoTreeBuilder(self.vlm, self.config.tree)
tree = builder.build(source_path)
# Phase 3: 执行 embedding 并保存(含 embedding
self._embed_tree(tree, cache_path=cache_path)
return tree
def _embed_tree(self, tree: TreeIndex, cache_path: Optional[str] = None) -> None:
"""对树的所有节点执行 embedding,可选回写缓存。
参数:
tree: 待 embed 的 TreeIndexembedding=None 的节点)。
cache_path: 若非 Noneembed 完成后回写到此路径(JSON 格式,含 embedding)。
实现细节:
调用 TreeIndex.embed_all,传入 EmbeddingModel.embed 作为 embed_fn。
embed_all 内部按 L2 分组批量处理 L3,减少 API 调用次数。
若 cache_path 非 None,保存时 include_embedding=True。
"""
log_msg("INFO", "开始对树执行 embedding")
tree.embed_all(
embed_fn=self.embed_model.embed,
model_name=self.config.embed.model_name,
embed_dim=self.embed_model.dim,
)
if cache_path is not None:
tree.save_json(cache_path, include_embedding=True)
log_msg("INFO", "embed_all 完成,缓存已更新(含 embedding)", cache_path=cache_path)
else:
log_msg("INFO", "embed_all 完成(仅内存,未写磁盘)")
def _load_or_build_video_tree(self, video_path: str) -> TreeIndex:
"""根据视频路径优先从缓存加载 TreeIndex,若无缓存则在线构建。
参数:
video_path: 视频文件路径或 youtube_id。
返回:
加载或构建完成的 TreeIndex 对象。
"""
# 如果传入的是 youtube_id,尝试拼凑路径
if not os.path.isfile(video_path):
video_path_full = os.path.join("data/videomme/videos", f"{video_path}.mp4")
if os.path.isfile(video_path_full):
video_path = video_path_full
return self.build_index(video_path, modality="video")
def query(
self,
question: str,
tree: TreeIndex | str,
modality: Optional[str] = None,
cache_path: Optional[str] = None,
) -> str:
"""执行端到端问答。
参数:
question: 用户查询字符串。
tree: TreeIndex 对象,或树 JSON 路径,或视频路径。
modality: 当 tree 为字符串且无法自动推断时,指定模态 ("text""video")。
cache_path: 若非 Noneembed 完成后回写到此路径。
返回:
生成的答案字符串。
"""
# Phase 0: 处理输入,确保得到 TreeIndex 对象
if isinstance(tree, str):
if tree.endswith(".json"):
log_msg("INFO", "直接从 JSON 路径加载 TreeIndex", path=tree)
tree_obj = TreeIndex.load_json(tree)
# 若 cache_path 未指定,使用 tree 的 JSON 路径
if cache_path is None:
cache_path = tree
elif modality == "video" or tree.endswith(".mp4"):
log_msg("INFO", "根据视频路径获取 TreeIndex", path=tree)
tree_obj = self._load_or_build_video_tree(tree)
else:
# 默认为文本
log_msg("INFO", "根据文本路径获取 TreeIndex", path=tree)
tree_obj = self.build_index(tree, modality="text")
else:
tree_obj = tree
# Phase 1: 确保树已 embed
if not tree_obj.is_embedded:
log_msg("INFO", "树尚未 embed,触发 embed_all 并回写缓存", cache_path=cache_path)
self._embed_tree(tree_obj, cache_path=cache_path)
# Phase 2: 嵌入查询
q: torch.Tensor = self.embed_model.embed_tensor(question) # [1, D]
# Phase 3: 递归检索
with torch.no_grad():
result = self.retriever(q, tree_obj)
log_msg(
"INFO",
"检索完成",
num_rounds=result["num_rounds"],
num_paths=len(result["paths"]),
question=question[:50],
)
# Phase 4: 生成答案
return self.generator.generate(
question, result["paths"], tree_obj, frame_hits=result.get("frame_hits")
)
@@ -0,0 +1,466 @@
"""
文本树构建模块
==============
将长文本通过 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,
)
+642
View File
@@ -0,0 +1,642 @@
"""
三层树索引核心数据结构
======================
定义 Video-Tree-TRM 的三层树状索引结构,是所有后续模块
builder、retriever、losses、pipeline)的基础依赖。
数据结构层次::
TreeIndex
└─ List[L1Node] 全局叙事节点
└─ List[L2Node] 片段级语义节点
└─ List[L3Node] 帧/细节级节点
与参考项目 (Tree-TRM/video_pyramid.py) 的关键区别:
- 统一嵌入空间:所有 embedding 均来自 text_embed(),无跨模态问题
- 序列化方式:pickle 整体序列化(而非 JSON + NPY 分文件存储)
- L3 全文本化:无需 VisualProjectionLayer
"""
from __future__ import annotations
import base64
import json
import pickle
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import numpy as np
from utils.logger_system import ensure, log_msg
# ---------------------------------------------------------------------------
# Embedding 序列化辅助函数
# ---------------------------------------------------------------------------
def _embed_to_str(arr: Optional[np.ndarray]) -> Optional[str]:
"""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: Optional[str]) -> Optional[np.ndarray]:
"""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)
# ---------------------------------------------------------------------------
# 元数据
# ---------------------------------------------------------------------------
@dataclass
class IndexMeta:
"""树索引元数据。
Attributes:
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: Optional[str] = None
embed_dim: Optional[int] = None
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
# ---------------------------------------------------------------------------
# 节点数据结构
# ---------------------------------------------------------------------------
@dataclass
class L3Node:
"""L3 帧/细节级节点(叶子层)。
代表最细粒度的语义单元,对应一个具体的描述片段。
Attributes:
id: 节点唯一标识。
description: 文本描述。
embedding: 文本嵌入向量,形状 [D]float32。
raw_content: 原始文本内容(可选)。
frame_path: 关联的帧图像路径(可选,仅视频模态)。
timestamp: 对应的时间戳(秒,可选)。
"""
id: str
description: str
embedding: Optional[np.ndarray] = None # [D]build 时为 Noneembed_all 后填充
raw_content: Optional[str] = None
frame_path: Optional[str] = None
timestamp: Optional[float] = None
@dataclass
class L2Node:
"""L2 片段级语义节点(中间层)。
连接 L1 宏观叙事与 L3 细节描述。
Attributes:
id: 节点唯一标识。
description: 片段文本描述。
embedding: 文本嵌入向量,形状 [D]float32。
time_range: 时间范围 (start, end)(秒,可选)。
children: 所属的 L3 子节点列表。
"""
id: str
description: str
embedding: Optional[np.ndarray] = None # [D]build 时为 Noneembed_all 后填充
time_range: Optional[Tuple[float, float]] = None
children: List[L3Node] = field(default_factory=list)
@dataclass
class L1Node:
"""L1 全局叙事节点(根层)。
代表最粗粒度的语义单元,包含宏观事件摘要。
Attributes:
id: 节点唯一标识。
summary: 高层叙事摘要。
embedding: 文本嵌入向量,形状 [D]float32。
time_range: 时间范围 (start, end)(秒,可选)。
children: 所属的 L2 子节点列表。
"""
id: str
summary: str
embedding: Optional[np.ndarray] = None # [D]build 时为 Noneembed_all 后填充
time_range: Optional[Tuple[float, float]] = None
children: List[L2Node] = field(default_factory=list)
# ------------------------------------------------------------------
# JSON 辅助方法(单个 L1 段的轻量序列化)
# ------------------------------------------------------------------
def to_dict(self, include_embedding: bool = False) -> Dict[str, Any]:
"""将当前 L1 节点(及其全部 L2/L3 子树)序列化为纯 dict。
参数:
include_embedding: 若 True,将 embedding 向量序列化为 base64 字符串。
返回:
包含 id/summary/time_range/children 的字典,可选包含 embedding。
"""
def l3_to_dict(n: L3Node) -> Dict[str, Any]:
d = {
"id": n.id,
"description": n.description,
"timestamp": n.timestamp,
"frame_path": n.frame_path,
"raw_content": n.raw_content,
}
if include_embedding:
d["embedding"] = _embed_to_str(n.embedding)
return d
def l2_to_dict(n: L2Node) -> Dict[str, Any]:
d = {
"id": n.id,
"description": n.description,
"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 = {
"id": self.id,
"summary": self.summary,
"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_nodes.append(
L3Node(
id=l3d["id"],
description=l3d["description"],
embedding=_embed_from_str(l3d.get("embedding")),
timestamp=l3d.get("timestamp"),
frame_path=l3d.get("frame_path"),
raw_content=l3d.get("raw_content"),
)
)
tr2 = l2d.get("time_range")
l2_nodes.append(
L2Node(
id=l2d["id"],
description=l2d["description"],
embedding=_embed_from_str(l2d.get("embedding")),
time_range=tuple(tr2) if tr2 else None,
children=l3_nodes,
)
)
tr1 = d.get("time_range")
return L1Node(
id=d["id"],
summary=d["summary"],
embedding=_embed_from_str(d.get("embedding")),
time_range=tuple(tr1) if tr1 else None,
children=l2_nodes,
)
# ---------------------------------------------------------------------------
# 树索引容器
# ---------------------------------------------------------------------------
@dataclass
class TreeIndex:
"""三层树索引容器。
组织和管理三层节点结构,提供嵌入矩阵提取、节点访问、
以及 pickle 序列化/反序列化接口。
典型工作流::
# 1. 构建索引
index = TreeIndex(metadata=meta, roots=[l1_node_1, l1_node_2])
# 2. 提取嵌入矩阵(用于 Tree-TRM 检索)
M_L1 = index.l1_embeddings() # [N1, D]
M_L2 = index.l2_embeddings_of(l1_idx=0) # [N2, D]
M_L3 = index.l3_embeddings_of(0, 1) # [N3, D]
# 3. 序列化
index.save("cache/my_index.pkl")
loaded = TreeIndex.load("cache/my_index.pkl")
Attributes:
metadata: 索引元数据。
roots: L1 节点列表。
"""
metadata: IndexMeta
roots: List[L1Node] = field(default_factory=list)
# ------------------------------------------------------------------ #
# 嵌入矩阵提取
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #
# 懒加载嵌入支持
# ------------------------------------------------------------------ #
@property
def is_embedded(self) -> bool:
"""检查所有节点是否已填充嵌入向量。
返回:
True 表示所有 L1/L2/L3 节点的 embedding 均非 NoneFalse 表示尚未 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[[Union[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 的节点执行(支持增量更新)。
"""
ensure(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:
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):
l3.embedding = emb
self.metadata.embed_model = model_name
self.metadata.embed_dim = embed_dim
log_msg("INFO", "embed_all 完成", model=model_name, embed_dim=embed_dim)
def l1_embeddings(self) -> np.ndarray:
"""返回所有 L1 节点的嵌入矩阵。
返回:
形状 [N1, D] 的 float32 矩阵。空树返回 [0, D]。
异常:
RuntimeError: 节点 embedding 尚未计算(请先调用 embed_all)。
"""
ensure(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 越界。
RuntimeError: embedding 尚未计算。
"""
ensure(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: 索引越界。
RuntimeError: embedding 尚未计算。
"""
ensure(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 序列化(主格式,无 embedding)
# ------------------------------------------------------------------ #
def to_dict(self, include_embedding: bool = False) -> Dict[str, Any]:
"""将树索引序列化为纯 Python dict。
参数:
include_embedding: 若 True,将所有节点的 embedding 向量序列化为 base64。
返回:
可直接 json.dump 的字典,结构为 {metadata, roots[...]}。
当 include_embedding=True 时,每个节点包含 embedding 字段。
"""
def l3_to_dict(n: L3Node) -> Dict[str, Any]:
d = {
"id": n.id,
"description": n.description,
"timestamp": n.timestamp,
"frame_path": n.frame_path,
"raw_content": n.raw_content,
}
if include_embedding:
d["embedding"] = _embed_to_str(n.embedding)
return d
def l2_to_dict(n: L2Node) -> Dict[str, Any]:
d = {
"id": n.id,
"description": n.description,
"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
metadata_dict = {
"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 实例(若 JSON 中有 embedding 字段,自动反序列化填充)。
"""
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"]:
l2_nodes: List[L2Node] = []
for l2d in r.get("children", []):
l3_nodes: List[L3Node] = []
for l3d in l2d.get("children", []):
l3_nodes.append(L3Node(
id=l3d["id"],
description=l3d["description"],
embedding=_embed_from_str(l3d.get("embedding")),
timestamp=l3d.get("timestamp"),
frame_path=l3d.get("frame_path"),
raw_content=l3d.get("raw_content"),
))
tr2 = l2d.get("time_range")
l2_nodes.append(L2Node(
id=l2d["id"],
description=l2d["description"],
embedding=_embed_from_str(l2d.get("embedding")),
time_range=tuple(tr2) if tr2 else None,
children=l3_nodes,
))
tr1 = r.get("time_range")
roots.append(L1Node(
id=r["id"],
summary=r["summary"],
embedding=_embed_from_str(r.get("embedding")),
time_range=tuple(tr1) if tr1 else None,
children=l2_nodes,
))
return cls(metadata=meta, roots=roots)
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)
log_msg(
"INFO",
f"树索引(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: 文件不存在。
"""
with open(path, "r", encoding="utf-8") as f:
d = json.load(f)
obj = cls.from_dict(d)
log_msg(
"INFO",
f"树索引(JSON)已从 {path} 加载",
n_l1=len(obj.roots),
is_embedded=obj.is_embedded,
)
return obj
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)
log_msg("INFO", "L1 中间结果已保存", path=path, l1_id=l1_node.id)
def load_l1_json(path: str) -> L1Node:
"""从 JSON 文件加载单个 L1 节点(embedding=None)。"""
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
node = L1Node.from_dict(data)
log_msg("INFO", "L1 中间结果已加载", path=path, l1_id=node.id)
return node
# ------------------------------------------------------------------ #
# 序列化(pickle,保留供向后兼容)
# ------------------------------------------------------------------ #
def save(self, path: str) -> None:
"""将整个树索引序列化到磁盘(pickle 格式)。
参数:
path: 保存文件路径(推荐 .pkl 后缀)。
"""
with open(path, "wb") as f:
pickle.dump(self, f, protocol=pickle.HIGHEST_PROTOCOL)
log_msg("INFO", f"树索引已保存至 {path}", n_l1=len(self.roots))
@classmethod
def load(cls, path: str) -> "TreeIndex":
"""从磁盘加载树索引。
.. warning::
pickle 反序列化可执行任意代码,切勿加载不受信任的文件。
如需安全替代方案,请考虑 JSON + NPY 分文件存储。
参数:
path: pickle 文件路径。
返回:
TreeIndex 实例。
异常:
FileNotFoundError: 文件不存在。
TypeError: 文件内容不是 TreeIndex 实例。
"""
with open(path, "rb") as f:
obj = pickle.load(f) # noqa: S301
if not isinstance(obj, cls):
msg = f"文件内容不是 TreeIndex 实例: {type(obj)}"
log_msg("ERROR", msg, path=path)
raise TypeError(msg)
log_msg("INFO", f"树索引已从 {path} 加载", n_l1=len(obj.roots))
return obj
@@ -0,0 +1,993 @@
"""
视频树构建模块
==============
将长视频通过 L2 轴心策略 + VLM 帧描述转化为三层 TreeIndex。
构建策略::
Step 1: _segment_video — 固定步长切分,确定 L1 时间边界
Step 2: L2 先行 — 每个 L2 clip 均匀 seek l2_representative_frames 帧(稀疏),VLM 生成片段描述(1-2句)
Step 3: L3 向下 — 注入 L2 上下文,VLM 批量帧描述(每帧1-2句)
Step 4: L1 向上 — 聚合 L2 描述,LLM 生成 L1 摘要(2-3句)
Step 5: 组装 TreeIndex
并发模型(异步版)::
build() → asyncio.run(_build_async())
_build_async():
asyncio.Semaphore(concurrency=16) 控制最大 VLM 并发数
Phase 1: asyncio.gather(所有L2任务) — 16路同时 VLM
Phase 2: asyncio.gather(所有L3任务) — 每个L3任务内的12批次同时并发
Phase 3: asyncio.gather(各L1摘要) — L1收齐后并发
ffmpeg 提帧在 ThreadPoolExecutor(max_workers=8) 中并行执行
L2 轴心策略解决了循环依赖:
- L2 描述不依赖 L3,从代表帧直接生成
- L3 注入 L2 上下文后逐帧描述
- L1 聚合 L2 描述,保证完整覆盖
帧持久化:
- 帧图像保存到 {cache_dir}/frames/{video_stem}/,长期有效
- 已提取的帧自动跳过(缓存复用)
使用方式::
builder = VideoTreeBuilder(vlm_client, config.tree)
index = builder.build("path/to/video.mp4") # 同步壳,内部 asyncio.run()
index.save("cache/my_video.pkl")
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import cv2
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,
load_l1_json,
save_l1_json,
)
# ---------------------------------------------------------------------------
# Prompt 常量
# ---------------------------------------------------------------------------
_L2_VIDEO_PROMPT = "用1-2句话描述以下视频片段的核心内容,与同级片段形成区分。"
_L3_VIDEO_PROMPT = (
'该片段的整体内容: "{l2_description}"\n'
"以下是该片段中连续的 {n} 帧画面。\n"
"对每帧用一到两句话描述其具体画面内容。\n"
"重点关注: 动作、物体变化、文字信息、人物表情。\n"
"不要重复片段整体描述,聚焦每帧的区分性信息。\n"
'只返回 JSON 数组,格式: ["帧1描述", "帧2描述", ...],不要其他内容。'
)
_L1_VIDEO_PROMPT = (
"以下是一个视频段落中各片段的描述:\n{l2_texts}\n"
"用2-3句话总结该段落的整体内容,涵盖所有片段的主题。"
)
# 每次 VLM 调用携带的最大帧数:5 帧 payload 小、JSON 解析成功率高
_L3_BATCH_SIZE = 5
_L3_SINGLE_PROMPT = (
'该片段的整体内容: "{l2_description}"\n'
"用一到两句话描述这帧画面的具体内容。"
"重点关注: 动作、物体变化、文字信息、人物表情。"
)
# ffmpeg 并发提帧的线程池大小(CPU 密集型,避免过度并发)
_FFMPEG_MAX_WORKERS = 8
# ---------------------------------------------------------------------------
# 主类
# ---------------------------------------------------------------------------
class VideoTreeBuilder:
"""视频模态树构建器(asyncio 真并发版)。
将长视频通过 L2 轴心策略(先构建 L2,再向下扩展 L3,向上聚合 L1)
转化为三层 TreeIndex。
并发架构:
build() 为同步壳,内部调用 asyncio.run(_build_async())。
_build_async() 使用 asyncio.Semaphore(concurrency) 控制并发 VLM 数量。
所有 VLM 调用通过 LLMClient 的异步接口(AsyncOpenAI)发起,零线程阻塞。
ffmpeg 提帧在独立 ThreadPoolExecutor 中并行,不阻塞事件循环。
属性:
vlm: VLM/LLM 客户端(用于图文和纯文本调用)。
config: 树构建配置。
_ffmpeg_pool: ffmpeg 专用线程池(max_workers=_FFMPEG_MAX_WORKERS)。
"""
def __init__(
self,
vlm: LLMClient,
config: TreeConfig,
) -> None:
"""初始化视频树构建器。
参数:
vlm: 已初始化的 VLM/LLM 客户端(LLMClient),需支持异步接口。
config: 树构建配置(TreeConfig),关键字段:
l1_segment_duration, l2_clip_duration, l3_fps,
l2_representative_frames, cache_dir, concurrency。
实现细节:
ffmpeg 线程池在构建器级别创建,所有异步协程共用,
避免每次提帧都重建线程池的开销。
"""
self.vlm = vlm
self.config = config
self._ffmpeg_pool = ThreadPoolExecutor(max_workers=_FFMPEG_MAX_WORKERS)
# 进度与中间结果目录均挂在 cache_dir 下,避免散落其它位置
self._cache_root = Path(self.config.cache_dir)
# ------------------------------------------------------------------
# URL 流式辅助方法
# ------------------------------------------------------------------
@staticmethod
def _is_url(path_or_url: str) -> bool:
"""判断输入是否为网络 URL(而非本地路径)。
参数:
path_or_url: 文件路径或 URL 字符串。
返回:
True 表示 URLFalse 表示本地路径。
"""
return path_or_url.startswith(("http://", "https://"))
@staticmethod
def _source_stem(video_path: str) -> str:
"""从视频路径或 YouTube URL 中提取短标识符,用于帧缓存目录命名。
参数:
video_path: 本地文件路径或 YouTube 视频页面 URL。
返回:
短字符串标识符(本地文件取 stem,YouTube URL 取 v= 后的视频 ID)。
"""
if "youtube.com/watch" in video_path or "youtu.be/" in video_path:
match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{8,15})", video_path)
if match:
return match.group(1)
stem = Path(video_path).stem
return stem[:64] if len(stem) > 64 else stem
@staticmethod
def _resolve_stream(url: str) -> str:
"""通过 yt-dlp 获取 YouTube 视频的 CDN 直链,供 cv2.VideoCapture 直接使用。
参数:
url: YouTube 视频页面 URL。
返回:
CDN HTTPS 直链(ffmpeg/OpenCV 可直接流式读取)。
"""
log_msg("INFO", "获取 YouTube CDN 直链", url=url)
result = subprocess.run(
[
"yt-dlp",
"-g",
"--format",
"best[ext=mp4][height<=720]/best[ext=mp4]/best",
url,
],
capture_output=True,
text=True,
timeout=30,
)
ensure(result.returncode == 0, f"yt-dlp 获取直链失败: {result.stderr.strip()}")
stream_url = result.stdout.strip().splitlines()[0]
ensure(stream_url.startswith("http"), f"yt-dlp 返回非 URL: {stream_url[:100]}")
log_msg("INFO", "CDN 直链获取成功", stream_url=stream_url[:80])
return stream_url
@staticmethod
def _get_video_duration(url: str) -> float:
"""通过 yt-dlp --dump-json 获取视频时长(秒)。
参数:
url: YouTube 视频页面 URL。
返回:
视频总时长(秒,浮点数)。
"""
log_msg("INFO", "获取视频时长元数据", url=url)
result = subprocess.run(
["yt-dlp", "--dump-json", "--no-playlist", url],
capture_output=True,
text=True,
timeout=30,
)
ensure(result.returncode == 0, f"yt-dlp 元数据获取失败: {result.stderr.strip()}")
meta = json.loads(result.stdout)
duration = float(meta.get("duration", 0))
ensure(duration > 0, f"视频时长读取异常: {duration}")
log_msg("INFO", "视频时长确认", duration_sec=round(duration, 1))
return duration
# ------------------------------------------------------------------
# 公共接口
# ------------------------------------------------------------------
def build(self, video_path: str) -> TreeIndex:
"""将长视频构建为三层 TreeIndex(同步壳,内部 asyncio.run 驱动)。
参数:
video_path: 视频文件路径(.mp4/.avi/.mkv 等)或 YouTube URL。
返回:
三层 TreeIndex 对象。
实现细节:
同步壳设计保持与 build_trees_batch.py 的接口兼容性。
每次调用 asyncio.run() 创建独立事件循环,多线程安全(各线程独立循环)。
"""
return asyncio.run(self._build_async(video_path))
# ------------------------------------------------------------------
# 核心异步构建逻辑
# ------------------------------------------------------------------
async def _build_async(self, video_path: str) -> TreeIndex:
"""异步构建三层 TreeIndex(真并发核心,L2→L3 链式触发)。
参数:
video_path: 视频文件路径或 YouTube URL。
返回:
三层 TreeIndex 对象。
实现细节:
并发架构:每个 L1 段内启动一组"L2→L3 链式协程"
L2 完成后立即触发 L3(不等待其他 L2),L3 完成后触发 L1 摘要。
各 L1 段独立并发,彼此不阻塞。
Semaphore(concurrency=16) 全局限制同时在途 VLM 调用数量。
关键调用链(每个 L2 clip 独立)::
_build_segment(i) → asyncio.gather(
_chain(i,0): _build_l2_video_async → _build_l3_task_async
_chain(i,1): _build_l2_video_async → _build_l3_task_async
...
) → _build_l1_video_async(i)
"""
# Phase 0: URL vs 本地文件处理
if self._is_url(video_path):
stream_url = self._resolve_stream(video_path)
duration_hint: Optional[float] = self._get_video_duration(video_path)
log_msg("INFO", "开始构建视频树索引(URL 流式模式)", source_url=video_path)
else:
ensure(os.path.isfile(video_path), f"视频文件不存在: {video_path}")
stream_url = video_path
duration_hint = None
log_msg("INFO", "开始构建视频树索引", video_path=video_path)
source_id = self._source_stem(video_path)
# Phase 1: 时间切分(同步,仅一次)
l1_ranges = self._segment_video(stream_url, duration_hint=duration_hint)
ensure(len(l1_ranges) > 0, "视频时间切分结果为空")
log_msg("INFO", "视频切分完成", l1_count=len(l1_ranges))
total_l1 = len(l1_ranges)
# Phase 1.1: 读取已有进度(支持断点续跑)
finished_l1_ids: set[int] = set()
progress = self._load_progress(source_id)
if progress is not None and progress.get("total_l1") == total_l1:
finished_l1_ids = set(progress.get("finished_l1_ids", []))
if finished_l1_ids:
log_msg(
"INFO",
"检测到中间进度,启用断点续跑",
stem=source_id,
finished_l1=list(sorted(finished_l1_ids)),
)
else:
# 进度不存在或形状不匹配时,从零开始,旧进度视为无效
if progress is not None:
log_msg(
"WARNING",
"进度文件与当前 L1 段数不一致,忽略旧进度",
stem=source_id,
recorded_total_l1=progress.get("total_l1"),
current_total_l1=total_l1,
)
# 创建 VLM 并发控制信号量(每视频独立,限制同时在途 VLM 请求数)
vlm_sem = asyncio.Semaphore(self.config.concurrency)
# Phase 2-5: 按 L1 段并发,段内 L2→L3 链式触发,L3 收齐后触发 L1
async def _build_segment(i: int, l1_range: Tuple[float, float]) -> L1Node:
"""单个 L1 段的完整构建:L2+L3 并发链式 → L1 摘要。
参数:
i: L1 段索引。
l1_range: L1 时间区间 (start, end)。
返回:
完整的 L1Node(含所有 L2 和 L3 子节点)。
实现细节:
段内所有 L2 clip 同时启动(asyncio.gather),
每个 clip 的 L2 VLM 完成后立即触发 L3,不等待其他 clip 的 L2。
所有 clip 的 L2+L3 完成后,触发 L1 文本摘要。
"""
clips = self._get_l2_clips(l1_range)
async def _chain(j: int, clip_range: Tuple[float, float]) -> Tuple[int, L2Node]:
"""L2→L3 链:L2 完成立即触发 L3,返回 (j, 含children的L2Node)。"""
l2_id = f"l1_{i}_l2_{j}"
l2_node = await self._build_l2_video_async(
stream_url, clip_range, l2_id, source_id, vlm_sem
)
log_msg("INFO", "L2 VLM 完成,已触发 L3 任务", l2_id=l2_id)
completed_l2 = await self._build_l3_task_async(
stream_url, l2_node, clip_range, source_id, i, j, vlm_sem
)
log_msg(
"INFO", "L3 完成",
l2_id=l2_id,
l3_count=len(completed_l2.children),
)
return (j, completed_l2)
# 所有 clip 同时启动(不等 L2 全部结束再开 L3)
pairs = await asyncio.gather(*[_chain(j, clip) for j, clip in enumerate(clips)])
ordered_l2 = [p[1] for p in sorted(pairs, key=lambda x: x[0])]
log_msg("INFO", "L1 触发", l1_id=f"l1_{i}")
l1_node = await self._build_l1_video_async(
ordered_l2, f"l1_{i}", l1_range, vlm_sem
)
log_msg(
"INFO", "L1 节点构建完成",
l1_id=f"l1_{i}",
l2_count=len(ordered_l2),
)
return l1_node
total_clips = sum(len(self._get_l2_clips(r)) for r in l1_ranges)
log_msg(
"INFO",
"开始并发构建(L2→L3链式,L1段间并发,支持断点续跑)",
total_l2=total_clips,
concurrency=self.config.concurrency,
)
# Phase 2: 并发构建尚未完成的 L1 段(段内 L2+L3 链式并发)
tasks: List[asyncio.Task[L1Node]] = []
task_indices: List[int] = []
for i, r in enumerate(l1_ranges):
# 已完成且中间 JSON 存在 → 直接复用
if i in finished_l1_ids and self._has_l1_intermediate(source_id, i):
continue
tasks.append(asyncio.create_task(_build_segment(i, r)))
task_indices.append(i)
new_l1_nodes: Dict[int, L1Node] = {}
if tasks:
results = await asyncio.gather(*tasks)
for idx, node in zip(task_indices, results):
# 每完成一个 L1 段就写入中间 JSON,并刷新进度文件
self._save_l1_intermediate(source_id, node, idx)
finished_l1_ids.add(idx)
new_l1_nodes[idx] = node
self._save_progress(source_id, total_l1, finished_l1_ids)
# Phase 3: 汇总所有 L1 段(中间 + 新生成)
l1_nodes: List[L1Node] = []
for i in range(total_l1):
if i in new_l1_nodes:
l1_nodes.append(new_l1_nodes[i])
continue
node = self._load_l1_intermediate(source_id, i)
ensure(node is not None, f"L1 段 {i} 缺失中间结果,无法恢复")
l1_nodes.append(node)
# Phase 6: 组装 TreeIndex
metadata = IndexMeta(
source_path=video_path,
modality="video",
created_at=datetime.now().isoformat(),
)
index = TreeIndex(metadata=metadata, roots=l1_nodes)
total_l2_count = sum(len(r.children) for r in l1_nodes)
total_l3_count = sum(len(l2.children) for r in l1_nodes for l2 in r.children)
log_json(
"video_tree_build",
{
"source_path": video_path,
"l1_count": len(l1_nodes),
"l2_count": total_l2_count,
"l3_count": total_l3_count,
"embedded": False,
},
)
log_msg(
"INFO",
"视频树索引构建完成",
l1=len(l1_nodes),
l2=total_l2_count,
l3=total_l3_count,
)
# 最终 JSON 写入成功后,清理由断点机制生成的中间文件
self._cleanup_intermediate_and_progress(source_id)
return index
# ------------------------------------------------------------------
# 内部方法:时间切分(同步,仅执行一次)
# ------------------------------------------------------------------
def _segment_video(
self,
video_path: str,
duration_hint: Optional[float] = None,
) -> List[Tuple[float, float]]:
"""读取视频总时长,按固定步长切分为 L1 时间区间列表。
参数:
video_path: 视频文件路径或 CDN 流式 URL。
duration_hint: 已知视频时长(秒)。传入时跳过 cv2 读取。
返回:
L1 时间区间列表,每项为 (start_sec, end_sec)。
"""
if duration_hint is not None:
total_duration = duration_hint
else:
cap = cv2.VideoCapture(video_path)
ensure(cap.isOpened(), f"无法打开视频文件: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
cap.release()
ensure(fps > 0, f"视频 FPS 读取异常: {fps}")
ensure(total_frames > 0, f"视频总帧数读取异常: {total_frames}")
total_duration = total_frames / fps
step = self.config.l1_segment_duration
ranges: List[Tuple[float, float]] = []
start = 0.0
while start < total_duration:
end = min(start + step, total_duration)
ranges.append((start, end))
start = end
log_msg(
"INFO",
"L1 时间切分",
total_duration=round(total_duration, 2),
l1_count=len(ranges),
)
return ranges
def _get_l2_clips(self, l1_range: Tuple[float, float]) -> List[Tuple[float, float]]:
"""将 L1 时间区间等分为 L2 clips。
参数:
l1_range: L1 时间区间 (start, end),单位秒。
返回:
L2 clip 时间区间列表。
"""
start, end = l1_range
step = self.config.l2_clip_duration
clips: List[Tuple[float, float]] = []
t = start
while t < end:
clip_end = min(t + step, end)
clips.append((t, clip_end))
t = clip_end
return clips
# ------------------------------------------------------------------
# 内部方法:帧提取(ffmpeg subprocess,在线程池执行)
# ------------------------------------------------------------------
def _ffmpeg_extract_frame(self, video_path: str, ts: float, out_path: str) -> bool:
"""用 ffmpeg subprocess 提取单帧图像,兼容 AV1/H.264 等所有编码格式。
参数:
video_path: 视频文件路径(本地 MP4 或 CDN URL)。
ts: 目标时间戳(秒)。
out_path: 输出 JPEG 文件路径。
返回:
True 表示提取成功,False 表示失败。
"""
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", f"{ts:.3f}",
"-i", video_path,
"-frames:v", "1",
"-q:v", "2",
"-y", out_path,
]
result = subprocess.run(cmd, capture_output=True)
return result.returncode == 0 and os.path.isfile(out_path)
async def _extract_frames_async(
self,
video_path: str,
time_range: Tuple[float, float],
fps: float,
source_id: Optional[str] = None,
) -> List[Tuple[str, float]]:
"""异步并发提取时间范围内的帧,保存到 cache 目录。
参数:
video_path: 视频文件路径或 CDN 流式 URL。
time_range: 提取时间区间 (start_sec, end_sec)。
fps: 提取帧率(帧/秒)。
source_id: 帧缓存目录名。
返回:
[(frame_path, timestamp_sec), ...],按时间顺序排列。
实现细节:
所有 ffmpeg 提取任务通过 run_in_executor(self._ffmpeg_pool, ...) 并发执行,
已缓存的帧直接跳过(无需调用 ffmpeg)。
ffmpeg 线程池 max_workers=_FFMPEG_MAX_WORKERS 防止过度并发占满 CPU。
"""
video_stem = source_id if source_id is not None else self._source_stem(video_path)
frame_dir = Path(self.config.cache_dir) / "frames" / video_stem
frame_dir.mkdir(parents=True, exist_ok=True)
start_sec, end_sec = time_range
step = 1.0 / fps
timestamps: List[float] = []
t = start_sec
while t < end_sec:
timestamps.append(t)
t += step
if not timestamps:
log_msg("WARNING", "帧提取时间区间内无有效时间戳", time_range=time_range, fps=fps)
return []
loop = asyncio.get_event_loop()
async def _extract_one(ts: float) -> Optional[Tuple[str, float]]:
"""提取单帧:缓存命中直接返回,否则在线程池中调用 ffmpeg。"""
frame_name = f"{start_sec:.1f}_{ts:.3f}.jpg"
frame_path = str(frame_dir / frame_name)
if os.path.isfile(frame_path):
return (frame_path, ts)
success = await loop.run_in_executor(
self._ffmpeg_pool,
self._ffmpeg_extract_frame, video_path, ts, frame_path,
)
if not success:
log_msg("WARNING", "帧读取失败,跳过", timestamp=ts, video_path=video_path)
return None
return (frame_path, ts)
# 并发提取所有帧(受 ffmpeg 线程池限制,不会无限并发)
results = await asyncio.gather(*[_extract_one(ts) for ts in timestamps])
return [r for r in results if r is not None]
# ------------------------------------------------------------------
# 内部方法:L1 中间结果与进度管理(断点续跑)
# ------------------------------------------------------------------
def _intermediate_dir(self, stem: str) -> Path:
"""获取某视频的中间结果目录路径。"""
return self._cache_root / "intermediate" / stem
def _progress_path(self, stem: str) -> Path:
"""获取某视频的进度文件路径。"""
return self._cache_root / "progress" / f"{stem}.json"
def _has_l1_intermediate(self, stem: str, l1_idx: int) -> bool:
"""检查某 L1 段的中间 JSON 是否存在。"""
path = self._intermediate_dir(stem) / f"l1_{l1_idx}.json"
return path.is_file()
def _save_l1_intermediate(self, stem: str, l1_node: L1Node, l1_idx: int) -> None:
"""将单个 L1 段的中间结果保存到 JSON 文件。"""
dir_path = self._intermediate_dir(stem)
dir_path.mkdir(parents=True, exist_ok=True)
out_path = dir_path / f"l1_{l1_idx}.json"
save_l1_json(str(out_path), l1_node)
def _load_l1_intermediate(self, stem: str, l1_idx: int) -> Optional[L1Node]:
"""从中间 JSON 加载单个 L1 段,若不存在则返回 None。"""
path = self._intermediate_dir(stem) / f"l1_{l1_idx}.json"
if not path.is_file():
return None
return load_l1_json(str(path))
def _load_progress(self, stem: str) -> Optional[Dict[str, object]]:
"""加载某视频的进度文件(若不存在则返回 None)。"""
path = self._progress_path(stem)
if not path.is_file():
return None
with open(path, "r", encoding="utf-8") as f:
try:
data: Dict[str, object] = json.load(f)
except json.JSONDecodeError:
log_msg("WARNING", "进度文件 JSON 解析失败,忽略", path=str(path))
return None
return data
def _save_progress(self, stem: str, total_l1: int, finished_l1_ids: set[int]) -> None:
"""将最新进度写回磁盘,支持断点续跑。"""
path = self._progress_path(stem)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"video_id": stem,
"total_l1": total_l1,
"finished_l1_ids": sorted(finished_l1_ids),
"updated_at": datetime.now().isoformat(),
}
if not path.is_file():
payload["created_at"] = payload["updated_at"]
else:
# 尝试保留旧 created_at
old = self._load_progress(stem)
if old and isinstance(old.get("created_at"), str):
payload["created_at"] = old["created_at"]
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
log_msg(
"INFO",
"进度文件已更新",
path=str(path),
total_l1=total_l1,
finished_l1=list(sorted(finished_l1_ids)),
)
def _cleanup_intermediate_and_progress(self, stem: str) -> None:
"""在最终 JSON 写入成功后清理中间结果与进度文件。"""
# 清理 progress
progress_path = self._progress_path(stem)
if progress_path.is_file():
try:
progress_path.unlink()
except OSError:
log_msg("WARNING", "删除进度文件失败", path=str(progress_path))
# 清理 intermediate 目录
inter_dir = self._intermediate_dir(stem)
if inter_dir.is_dir():
for child in inter_dir.glob("l1_*.json"):
try:
child.unlink()
except OSError:
log_msg("WARNING", "删除 L1 中间 JSON 失败", path=str(child))
try:
# 目录可能仍有其它调试文件,忽略删除异常
inter_dir.rmdir()
except OSError:
pass
# ------------------------------------------------------------------
# 内部方法:异步节点构建
# ------------------------------------------------------------------
async def _build_l2_video_async(
self,
video_path: str,
clip_range: Tuple[float, float],
l2_id: str,
source_id: Optional[str],
vlm_sem: asyncio.Semaphore,
) -> L2Node:
"""异步构建 L2 视频节点(代表帧 VLM 描述)。
参数:
video_path: 视频文件路径或 CDN 流式 URL。
clip_range: L2 clip 时间区间 (start, end),单位秒。
l2_id: 节点 ID。
source_id: 帧缓存目录名。
vlm_sem: VLM 并发控制信号量。
返回:
L2Nodechildren 为空,由后续 L3 阶段填充)。
实现细节:
均匀采样 l2_representative_frames 帧,并行 ffmpeg 提取,
async with vlm_sem 限制 VLM 并发量。
"""
start_sec, end_sec = clip_range
n_rep = self.config.l2_representative_frames
if n_rep == 1:
timestamps = [(start_sec + end_sec) / 2.0]
else:
step = (end_sec - start_sec) / (n_rep - 1)
timestamps = [start_sec + i * step for i in range(n_rep)]
video_stem = source_id if source_id is not None else self._source_stem(video_path)
frame_dir = Path(self.config.cache_dir) / "frames" / video_stem
frame_dir.mkdir(parents=True, exist_ok=True)
loop = asyncio.get_event_loop()
async def _extract_rep(ts: float) -> Optional[str]:
frame_name = f"l2_{ts:.3f}.jpg"
frame_path = str(frame_dir / frame_name)
if os.path.isfile(frame_path):
return frame_path
success = await loop.run_in_executor(
self._ffmpeg_pool,
self._ffmpeg_extract_frame, video_path, ts, frame_path,
)
if not success:
log_msg("WARNING", "L2 代表帧读取失败,跳过", timestamp=ts)
return None
return frame_path
# 并发提取所有代表帧
rep_results = await asyncio.gather(*[_extract_rep(ts) for ts in timestamps])
rep_frames = [p for p in rep_results if p is not None]
ensure(len(rep_frames) > 0, f"L2 节点 {l2_id} 代表帧提取结果为空")
# VLM 调用受信号量保护
async with vlm_sem:
description = await self.vlm.chat_with_images_async(
_L2_VIDEO_PROMPT, images=rep_frames
)
return L2Node(
id=l2_id,
description=description,
embedding=None,
time_range=clip_range,
)
async def _build_l3_task_async(
self,
video_path: str,
l2_node: L2Node,
clip_range: Tuple[float, float],
source_id: str,
l1_i: int,
l2_j: int,
vlm_sem: asyncio.Semaphore,
) -> L2Node:
"""异步 L3 任务:并发提帧 + 批次级并发 VLM 帧描述。
参数:
video_path: 视频文件路径或 CDN 流式 URL。
l2_node: 已构建的 L2 节点。
clip_range: L2 clip 时间区间。
source_id: 帧缓存目录名。
l1_i: 父 L1 索引。
l2_j: 父 L2 索引。
vlm_sem: VLM 并发控制信号量。
返回:
已填充 children 的 L2Node。
实现细节:
提帧阶段完全并行(受 ffmpeg 线程池限制);
VLM 调用阶段:12个批次同时提交(asyncio.gather),受信号量限流。
"""
all_frames = await self._extract_frames_async(
video_path, clip_range, self.config.l3_fps, source_id=source_id
)
l3_nodes = await self._build_l3_video_async(
all_frames, l2_node.description, l1_i, l2_j, vlm_sem
)
l2_node.children = l3_nodes
return l2_node
async def _build_l3_video_async(
self,
frames: List[Tuple[str, float]],
l2_description: str,
l1_i: int,
l2_j: int,
vlm_sem: asyncio.Semaphore,
) -> List[L3Node]:
"""异步批次级并发构建 L3 节点(核心加速点)。
参数:
frames: [(frame_path, timestamp), ...]。
l2_description: L2 节点描述,注入 prompt 上下文。
l1_i: 父 L1 索引(用于节点 ID 生成)。
l2_j: 父 L2 索引(用于节点 ID 生成)。
vlm_sem: VLM 并发控制信号量。
返回:
L3Node 列表,每项对应一帧。
实现细节:
将全部帧按 _L3_BATCH_SIZE 分批,所有批次同时提交(asyncio.gather),
每批通过 vlm_sem 限流,实现批次级真并发。
对比旧版串行:12批 × 6s = 72s → 现在 ~6s(受信号量限流取最慢批次)。
"""
ensure(len(frames) > 0, f"L3 帧列表为空 (l1={l1_i}, l2={l2_j})")
# Phase 1: 构建所有批次的协程(同时提交,asyncio.gather 并发执行)
batches: List[List[Tuple[str, float]]] = []
for batch_start in range(0, len(frames), _L3_BATCH_SIZE):
batches.append(frames[batch_start : batch_start + _L3_BATCH_SIZE])
batch_results: List[List[str]] = list(
await asyncio.gather(
*[
self._call_vlm_batch_async(
batch, l2_description, l1_i, l2_j, vlm_sem
)
for batch in batches
]
)
)
# Phase 2: 展平所有批次描述,构建 L3 节点
all_descriptions: List[str] = [
desc for batch_descs in batch_results for desc in batch_descs
]
nodes: List[L3Node] = []
for k, (desc, (frame_path, ts)) in enumerate(zip(all_descriptions, frames)):
nodes.append(
L3Node(
id=f"l1_{l1_i}_l2_{l2_j}_l3_{k}",
description=desc,
embedding=None,
raw_content=None,
frame_path=frame_path,
timestamp=ts,
)
)
return nodes
async def _call_vlm_batch_async(
self,
batch: List[Tuple[str, float]],
l2_description: str,
l1_i: int,
l2_j: int,
vlm_sem: asyncio.Semaphore,
) -> List[str]:
"""异步单批次 VLM 调用(≤ _L3_BATCH_SIZE 帧),解析失败时逐帧 fallback。
参数:
batch: 本批帧列表 [(frame_path, ts), ...]。
l2_description: L2 描述,用于 prompt 和 fallback prompt。
l1_i: 父 L1 索引(日志用)。
l2_j: 父 L2 索引(日志用)。
vlm_sem: VLM 并发控制信号量。
返回:
与 batch 等长的描述文本列表。
实现细节:
async with vlm_sem 确保并发量不超过 config.concurrency。
fallback 时逐帧并发(asyncio.gather),同样受信号量保护。
"""
batch_paths = [fp for fp, _ in batch]
n = len(batch_paths)
prompt = _L3_VIDEO_PROMPT.format(l2_description=l2_description, n=n)
# Phase 1: 尝试批量调用
try:
async with vlm_sem:
raw = await self.vlm.chat_with_images_async(prompt, images=batch_paths)
descriptions = self._parse_json_descriptions(raw, n)
if descriptions is not None:
return descriptions
log_msg(
"WARNING",
"L3 小批量 VLM JSON 解析失败,对本批逐帧 fallback",
l1=l1_i,
l2=l2_j,
batch_n=n,
raw_preview=raw[:100],
)
except Exception as exc:
log_msg(
"WARNING",
f"L3 小批量 VLM 调用异常,对本批逐帧 fallback: {exc}",
l1=l1_i,
l2=l2_j,
batch_n=n,
)
# Phase 2: 逐帧 fallback(并发,受信号量保护)
single_prompt = _L3_SINGLE_PROMPT.format(l2_description=l2_description)
async def _single_frame(fp: str) -> str:
async with vlm_sem:
return await self.vlm.chat_with_images_async(single_prompt, images=[fp])
return list(await asyncio.gather(*[_single_frame(fp) for fp in batch_paths]))
async def _build_l1_video_async(
self,
l2_children: List[L2Node],
l1_id: str,
l1_range: Tuple[float, float],
vlm_sem: asyncio.Semaphore,
) -> L1Node:
"""异步构建 L1 节点(LLM 文本摘要)。
参数:
l2_children: 该 L1 节点下的所有 L2 节点。
l1_id: 节点 ID。
l1_range: L1 时间区间 (start, end),单位秒。
vlm_sem: VLM 并发控制信号量。
返回:
L1Nodechildren 已赋值)。
"""
ensure(len(l2_children) > 0, f"L1 节点 {l1_id} 没有 L2 子节点")
l2_texts = "\n".join(f"- {node.description}" for node in l2_children)
prompt = _L1_VIDEO_PROMPT.format(l2_texts=l2_texts)
async with vlm_sem:
summary = await self.vlm.chat_async(prompt)
log_msg("INFO", "L1 触发", l1_id=l1_id)
return L1Node(
id=l1_id,
summary=summary,
embedding=None,
time_range=l1_range,
children=l2_children,
)
# ------------------------------------------------------------------
# 内部方法:JSON 解析(同步,纯 CPU)
# ------------------------------------------------------------------
def _parse_json_descriptions(
self, raw: str, expected_n: int
) -> Optional[List[str]]:
"""从 VLM 输出中解析 JSON 描述数组。
参数:
raw: VLM 原始返回字符串。
expected_n: 期望的描述条数。
返回:
成功解析且长度匹配时返回 List[str],否则返回 None。
"""
raw = raw.strip()
code_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, re.DOTALL)
if code_match:
raw = code_match.group(1)
if not raw.startswith("["):
return None
try:
items: List[str] = json.loads(raw)
except json.JSONDecodeError:
return None
if not isinstance(items, list) or len(items) != expected_n:
return None
return [str(item).strip() for item in items]