视频 RAG 从零实现:镜头切分、字幕对齐与时间段引用

文档 RAG 找到一段文字后,可以把页码或段落编号作为引用;视频却没有天然的"第几段"。用户问"讲师什么时候演示了缓存击穿""哪一段出现了红色告警灯",系统不仅要回答内容,还要返回可以跳转播放的时间段。只做一次语音转写再对字幕向量化,能回答台词,却看不到画面;固定每三十秒截一帧,能得到图像,却经常把一个动作拆开,或者把两个主题混在同一块里。

视频 RAG 的难点不是把视频文件塞进向量数据库,而是建立可靠的时间轴:镜头、语音、字幕、关键帧和最终证据都必须落在同一个时间坐标上。检索结果还要保留来源视频、起止时间和生成方式,回答才能给出"00:12:18---00:12:46"这样的可核验引用。本文用本地文件和可运行的 Python 代码搭建一条最小但完整的链路,并解释哪些部分可以离线完成,哪些地方必须根据业务数据重新评测。

1. 先定义目标:回答必须能回到原视频

本文以企业培训视频为例。每个文件可能包含讲师画面、幻灯片、屏幕操作和问答环节,用户会提出三类问题。第一类只依赖语音,例如"课程如何定义缓存雪崩";第二类只依赖画面,例如"在哪一段展示了 Redis 配置界面";第三类需要两种证据结合,例如"讲师演示故障时,监控曲线出现了什么变化"。

合格输出至少包含答案、来源视频、开始时间、结束时间、证据摘要和播放器跳转链接。系统找不到足够证据时应该明确拒答,而不是从模型常识补一个看似合理的结论。这里把"能跳到证据"当作主验收条件,因为时间引用错误会直接破坏用户信任:回答内容即使大体正确,点击后却看到无关片段,仍然是不可用结果。

视频 RAG 不负责视频生成、实时监控或任意长视频的逐帧理解。它适合已经落盘、允许离线预处理,并且用户更关心"在什么位置讲过或出现过什么"的场景。直播流、体育动作的毫秒级判罚、医学影像诊断等任务需要不同的数据精度和审核流程,不能直接套用本文实现。

2. 整体链路:先建时间轴,再做检索

一个可维护的视频 RAG 可以拆成离线入库和在线问答两条路径。离线侧负责解析媒体、切镜头、转写语音、提取关键帧、对齐并建立索引;在线侧负责分析查询、分别检索文字与画面证据、融合排序、生成回答和时间段引用。
#mermaid-svg-Z8LBP8MmlzIMduD6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Z8LBP8MmlzIMduD6 .error-icon{fill:#552222;}#mermaid-svg-Z8LBP8MmlzIMduD6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Z8LBP8MmlzIMduD6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .marker.cross{stroke:#333333;}#mermaid-svg-Z8LBP8MmlzIMduD6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Z8LBP8MmlzIMduD6 p{margin:0;}#mermaid-svg-Z8LBP8MmlzIMduD6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .cluster-label text{fill:#333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .cluster-label span{color:#333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .cluster-label span p{background-color:transparent;}#mermaid-svg-Z8LBP8MmlzIMduD6 .label text,#mermaid-svg-Z8LBP8MmlzIMduD6 span{fill:#333;color:#333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .node rect,#mermaid-svg-Z8LBP8MmlzIMduD6 .node circle,#mermaid-svg-Z8LBP8MmlzIMduD6 .node ellipse,#mermaid-svg-Z8LBP8MmlzIMduD6 .node polygon,#mermaid-svg-Z8LBP8MmlzIMduD6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Z8LBP8MmlzIMduD6 .rough-node .label text,#mermaid-svg-Z8LBP8MmlzIMduD6 .node .label text,#mermaid-svg-Z8LBP8MmlzIMduD6 .image-shape .label,#mermaid-svg-Z8LBP8MmlzIMduD6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Z8LBP8MmlzIMduD6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Z8LBP8MmlzIMduD6 .rough-node .label,#mermaid-svg-Z8LBP8MmlzIMduD6 .node .label,#mermaid-svg-Z8LBP8MmlzIMduD6 .image-shape .label,#mermaid-svg-Z8LBP8MmlzIMduD6 .icon-shape .label{text-align:center;}#mermaid-svg-Z8LBP8MmlzIMduD6 .node.clickable{cursor:pointer;}#mermaid-svg-Z8LBP8MmlzIMduD6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .arrowheadPath{fill:#333333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Z8LBP8MmlzIMduD6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Z8LBP8MmlzIMduD6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Z8LBP8MmlzIMduD6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Z8LBP8MmlzIMduD6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Z8LBP8MmlzIMduD6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Z8LBP8MmlzIMduD6 .cluster text{fill:#333;}#mermaid-svg-Z8LBP8MmlzIMduD6 .cluster span{color:#333;}#mermaid-svg-Z8LBP8MmlzIMduD6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Z8LBP8MmlzIMduD6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Z8LBP8MmlzIMduD6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Z8LBP8MmlzIMduD6 .icon-shape,#mermaid-svg-Z8LBP8MmlzIMduD6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Z8LBP8MmlzIMduD6 .icon-shape p,#mermaid-svg-Z8LBP8MmlzIMduD6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Z8LBP8MmlzIMduD6 .icon-shape rect,#mermaid-svg-Z8LBP8MmlzIMduD6 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Z8LBP8MmlzIMduD6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Z8LBP8MmlzIMduD6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Z8LBP8MmlzIMduD6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原始视频
ffprobe 媒体探测
镜头边界检测
音轨抽取
Whisper 转写
关键帧与视觉描述
字幕片段
时间轴对齐与 Chunk 构建
文本向量索引
视觉向量索引
用户问题
查询分析与双路召回
时间去重与重排
基于证据生成答案
时间段引用与播放器跳转

这里没有让视觉模型直接"看完整部视频"。长视频抽成数万帧后成本很高,而且模型上下文仍然无法保留完整时间关系。更实际的做法是先用确定性工具压缩:镜头边界提供视觉结构,ASR 提供语义主干,关键帧描述补充字幕看不到的信息。生成模型只处理召回到的少量候选片段。

3. 时间坐标是第一等数据

视频容器里的时间不一定从零开始,也不一定是恒定帧率。编辑软件拼接、网络录制和转码都可能产生非零起始时间、可变帧率或音视频时间基偏移。如果用"帧号除以 25"计算时间,一旦输入不是固定 25 FPS,十几分钟后就可能产生明显漂移。正确做法是保留解码器提供的时间戳,并统一换算成整数毫秒。

数据库中不要只保存格式化字符串"12:18"。展示格式会变化,也不利于区间计算。核心字段使用 start_ms 和 end_ms,规定左闭右开区间,即片段覆盖 [start_ms, end_ms)。两个片段重叠长度可以稳定计算,零长度片段则在入库时直接拒绝。播放器链接再由毫秒派生,例如 /watch/video-7?t=738,而不是反过来解析页面字符串。

还要区分媒体时间和墙上时钟。培训视频里的第 738 秒是媒体时间;"2026 年 9 月 28 日 10:05"是录制发生时间。检索引用通常使用前者,审计或多摄像头同步才需要后者。若转码裁掉片头,应保存从原始时间到派生文件时间的映射,不能悄悄把新时间戳冒充原始证据位置。

4. 环境与目录约定

示例采用 Python 3.11、FFmpeg 7 或兼容版本、PySceneDetect、OpenAI Whisper、NumPy 和一个可替换的向量库。先确认 ffmpeg -version 与 ffprobe -version 可用,再创建虚拟环境:

bash 复制代码
python -m venv .venv
.venv\Scripts\activate
pip install scenedetect[opencv] openai-whisper numpy

目录建议把不可变原件和派生数据分开:

text 复制代码
video-rag/
├─ source/                 # 原始视频,只读
├─ work/video-001/
│  ├─ audio.wav
│  ├─ scenes.json
│  ├─ transcript.json
│  └─ frames/
├─ index/                  # 向量与元数据索引
└─ scripts/

Whisper 依赖 FFmpeg 读取媒体。GPU 不是必需条件,但长视频在 CPU 上转写会很慢;生产环境应先用代表性视频测量实时系数,而不是凭模型参数推算吞吐。示例没有绑定具体向量数据库,原因是时间轴数据模型比数据库品牌更重要:FAISS、PostgreSQL 加 pgvector 或托管检索服务都能承载,只要能连同元数据返回。

5. 数据模型:Chunk 不是一段孤立文本

每个检索单元都要能回溯到源文件和处理版本。建议至少保存以下字段:video_id、chunk_id、start_ms、end_ms、transcript、visual_summary、frame_refs、scene_ids、source_sha256、pipeline_version 和权限标签。向量属于派生数据,原视频、时间轴元数据和处理版本才是事实源。

source_sha256 用于识别内容是否变化,不能只看文件名。相同文件重新处理时,先写入新的 pipeline 版本,索引完成并通过校验后再原子切换别名;不要边删旧向量边写新向量,否则在线查询会短暂缺数据。删除源视频时也要通过反向索引清理字幕、帧、向量和缓存,避免用户已经撤权,旧证据仍能被搜索。

Chunk 的边界可以从镜头开始,但不应机械等同于镜头。快速剪辑会产生两秒镜头,逐个入库导致语义碎片;一场长达十分钟的固定机位讲解又不能作为一个块。更稳妥的规则是:以镜头为候选边界,合并短镜头,按语句结束点切分长镜头,并允许相邻块保留少量字幕重叠。最终块常见长度可从二十到九十秒起步,具体阈值必须由真实问题集决定。

6. 镜头检测:边界是候选,不是真理

PySceneDetect 的 ContentDetector 根据相邻帧内容变化寻找切点,适合幻灯片切换、镜头转换和屏幕页面跳转。阈值过低会把鼠标移动、动画和摄像机抖动误判成切镜;阈值过高又会漏掉渐变或相似页面。先抽取十到二十个代表视频人工标注边界,再决定默认阈值,不应把网络示例里的数字当成通用最优值。

下面脚本输出毫秒级镜头区间,并把过短镜头并入前一个片段。它使用 PySceneDetect 官方公开接口,结果仍需结合业务素材验证。

python 复制代码
# detect_scenes.py
from __future__ import annotations

import json
from pathlib import Path

from scenedetect import ContentDetector, SceneManager, open_video


def detect_scenes(video_path: str, threshold: float = 27.0) -> list[dict]:
    video = open_video(video_path)
    manager = SceneManager()
    manager.add_detector(ContentDetector(threshold=threshold, min_scene_len=15))
    manager.detect_scenes(video)
    scenes = manager.get_scene_list(start_in_scene=True)
    return [
        {
            "scene_id": f"scene-{index:05d}",
            "start_ms": round(start.get_seconds() * 1000),
            "end_ms": round(end.get_seconds() * 1000),
        }
        for index, (start, end) in enumerate(scenes)
        if end.get_seconds() > start.get_seconds()
    ]


def merge_short_scenes(scenes: list[dict], minimum_ms: int = 2500) -> list[dict]:
    merged: list[dict] = []
    for scene in scenes:
        duration = scene["end_ms"] - scene["start_ms"]
        if merged and duration < minimum_ms:
            merged[-1] = {**merged[-1], "end_ms": scene["end_ms"]}
        else:
            merged.append(dict(scene))
    return [
        {**scene, "scene_id": f"scene-{index:05d}"}
        for index, scene in enumerate(merged)
    ]


if __name__ == "__main__":
    result = merge_short_scenes(detect_scenes("source/demo.mp4"))
    Path("work/scenes.json").write_text(
        json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    assert all(x["start_ms"] < x["end_ms"] for x in result)

切镜结果还要做三个检查:第一个片段是否覆盖有效媒体起点,最后一个片段是否到达媒体末尾,相邻片段是否出现不合理的空洞或重叠。广告闪白、PPT 动画和画中画是常见误切来源。对固定机位课堂,可以把 ASR 的长停顿与标题页 OCR 变化一起作为辅助边界;对影视内容,镜头检测通常更可靠,但对白可能跨越多个镜头,需要后续字幕对齐来恢复语义连续性。

7. 语音转写:保留原始分段与语言信息

Whisper 的 transcribe 会返回带起止秒数的 segments。入库时保留模型原始分段,不要先拼成整篇文本再按字符数切割,因为字符切割无法恢复停顿和说话顺序。音频统一转为单声道 16 kHz 可以减少格式差异,但必须记录转码命令、工具版本和源文件摘要,方便重现。

python 复制代码
# transcribe.py
from __future__ import annotations

import json
from pathlib import Path

import whisper


def transcribe(media_path: str, model_name: str = "small") -> dict:
    model = whisper.load_model(model_name)
    raw = model.transcribe(
        media_path,
        language="zh",
        fp16=False,
        verbose=False,
        condition_on_previous_text=True,
    )
    segments = []
    for item in raw.get("segments", []):
        text = str(item.get("text", "")).strip()
        start_ms = round(float(item["start"]) * 1000)
        end_ms = round(float(item["end"]) * 1000)
        if text and end_ms > start_ms:
            segments.append({
                "segment_id": f"asr-{len(segments):05d}",
                "start_ms": start_ms,
                "end_ms": end_ms,
                "text": text,
            })
    return {"language": raw.get("language"), "segments": segments}


if __name__ == "__main__":
    data = transcribe("source/demo.mp4")
    Path("work/transcript.json").write_text(
        json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    assert all(s["start_ms"] < s["end_ms"] for s in data["segments"])

示例固定中文只是为了让结果可控。真实系统应先做语言检测,或允许上传者指定语言;多人会议还需要说话人分离,但说话人标签属于额外模型输出,不能默认准确。专有名词、型号和缩写应通过词表纠错,并同时保留原始转写。若只保存"修正后字幕",后续无法判断错误来自 ASR 还是人工规则。

静音并不代表无内容。软件演示可能几分钟没有讲话,但画面操作非常关键。因此后续 Chunk 不能只由字幕驱动;没有文字的镜头仍应生成视觉描述并允许被检索。反过来,纯播客视频几乎没有视觉变化,就不必为每个相似帧支付视觉模型成本。

8. 字幕与镜头对齐:按重叠量分配证据

字幕片段经常跨越切镜点。把整句只分配给起始镜头,会让后一镜头丢失语义;把整句复制到所有相交镜头,又会制造重复召回。可以先按时间交集计算归属比例:交集达到字幕时长的一定比例时关联该镜头;跨边界较均匀的句子允许关联两侧,并记录重叠毫秒数供排序使用。

python 复制代码
# align.py
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Span:
    span_id: str
    start_ms: int
    end_ms: int
    text: str = ""


def overlap_ms(left: Span, right: Span) -> int:
    return max(0, min(left.end_ms, right.end_ms) - max(left.start_ms, right.start_ms))


def align_subtitles(
    scenes: list[Span], subtitles: list[Span], minimum_ratio: float = 0.20
) -> dict[str, list[dict]]:
    result = {scene.span_id: [] for scene in scenes}
    for subtitle in subtitles:
        duration = max(1, subtitle.end_ms - subtitle.start_ms)
        candidates = []
        for scene in scenes:
            shared = overlap_ms(scene, subtitle)
            if shared and shared / duration >= minimum_ratio:
                candidates.append((scene.span_id, shared))
        if not candidates:
            midpoint = (subtitle.start_ms + subtitle.end_ms) // 2
            candidates = [
                (scene.span_id, 1)
                for scene in scenes
                if scene.start_ms <= midpoint < scene.end_ms
            ]
        for scene_id, shared in candidates:
            result[scene_id].append({
                "subtitle_id": subtitle.span_id,
                "text": subtitle.text,
                "overlap_ms": shared,
            })
    return result


if __name__ == "__main__":
    scenes = [Span("s1", 0, 5000), Span("s2", 5000, 10000)]
    subtitles = [Span("u1", 4200, 6200, "现在切换到配置页面")]
    aligned = align_subtitles(scenes, subtitles)
    assert aligned["s1"] and aligned["s2"]

这段算法是可解释的基线,不宣称解决所有同步问题。若字幕整体比画面晚两秒,应先估计全局偏移再对齐;若漂移随时间增长,通常是采样率、可变帧率或转码时间基问题,不能靠给每个片段随意扩窗掩盖。可以抽取视频开头、中间和结尾的已知口型或幻灯片切换点,分别测量偏移,判断是常量偏差还是线性漂移。

9. 关键帧与视觉描述:少而有代表性

每个镜头只取中间帧很省事,却可能错过开头弹出的错误提示或结尾才完成的操作。实用策略是短镜头取中间帧,长镜头取起点后、中心和结束前三个候选,再用图像相似度去重。屏幕录制还可以在 OCR 文本显著变化时增加帧;摄像机视频则关注构图和对象变化。

关键帧有两种索引路线。第一种直接用 CLIP 一类图文共同嵌入模型,把问题和图像映射到可比较空间,适合搜索颜色、对象和场景;第二种让视觉语言模型生成受约束描述,再把描述放入文本索引,适合"配置页面上哪个参数被修改"这类细节问题。生产系统通常同时保留视觉向量和短描述,但不要把生成描述当作原始事实,它可能遗漏或误读画面。

视觉描述提示词应要求只陈述可见内容,输出对象、动作、界面文字和不确定项,并禁止根据上下文猜测。OCR 文本要保存置信度和帧位置;低置信字符可以用于召回,不能直接作为最终精确数值。包含人脸、工牌、聊天窗口或客户数据的帧应在进入外部模型前脱敏,原始帧访问也要继承视频权限。

10. 构建多模态 Chunk

对齐完成后,将相邻短镜头合成语义块。合并规则可以综合总时长、字幕句末、静音长度、视觉变化和标题页变化。每个 Chunk 生成两段可检索文本:spoken_text 保存按时间排序的字幕,visual_text 保存关键帧描述与可靠 OCR。二者不要提前混成一段无法区分来源的文字,因为在线查询需要知道证据来自声音还是画面。

Chunk 之间允许五到十秒的语音重叠,避免答案刚好落在边界,但需要用 canonical_segment_ids 标记原始字幕,在线融合时据此去重。视觉帧通常不必重叠复制,只保留引用。若一个块没有字幕,文本字段为空而不是写"无字幕";若视觉处理失败,状态字段明确标记 visual_status=failed,不能把失败和"画面没有内容"混为一谈。

元数据还应保存可过滤维度,例如课程、部门、语言、上传者、保密等级和有效期。过滤必须在向量召回前或检索引擎内部执行。先全库召回再在应用层删掉无权结果,不仅浪费计算,还可能让相似度、日志和缓存泄露受限内容。

11. 在线检索:问题决定走哪条证据通道

用户问"讲师怎么解释缓存穿透",文本召回应占更高权重;问"哪一段出现红色错误页",视觉召回应占更高权重;问"演示失败时讲师采取了什么措施",则需要双路召回。最简单的查询分类器可以用规则与少量标注样本实现,不必一开始就再调用一个大模型。无论如何,不能因为分类器判断是文本问题就完全关闭视觉通道,可以保留低权重候选作为兜底。

双路结果的原始相似度通常不在同一尺度,直接相加没有意义。可以分别按排名转换为倒数排名分数,再按查询类型加权;随后对时间高度重叠的结果做合并,避免前五名全是同一分钟的重复块。重排输入包含问题、字幕、视觉描述和时间邻居摘要,输出只调整候选顺序,不改写证据内容。

召回还应扩展相邻片段。动作原因可能在前一个块,结果可能在后一个块,因此对高分命中向前后各取一个邻居通常有帮助。但扩展必须受总时长和令牌预算约束,也不能跨越权限或不同视频。把十分钟相邻内容全部塞给模型,只会重新制造长上下文噪声。

12. 生成带时间段的可验证答案

生成模型拿到的证据应使用稳定编号,例如 [V3 00:12:18-00:12:46],并明确要求每个事实只能引用提供的编号。模型输出结构化 JSON:答案句、证据编号和不确定性。服务端再根据编号查元数据生成播放器链接,绝不能让模型自行拼 URL 或时间值。

python 复制代码
# citations.py
from __future__ import annotations

from urllib.parse import quote


def format_time(milliseconds: int) -> str:
    total = max(0, milliseconds // 1000)
    hours, remain = divmod(total, 3600)
    minutes, seconds = divmod(remain, 60)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}"


def build_citation(hit: dict, allowed_video_ids: set[str]) -> dict:
    video_id = str(hit["video_id"])
    start_ms = int(hit["start_ms"])
    end_ms = int(hit["end_ms"])
    if video_id not in allowed_video_ids:
        raise PermissionError("video is not visible to this user")
    if start_ms < 0 or end_ms <= start_ms:
        raise ValueError("invalid media interval")
    return {
        "label": f"{format_time(start_ms)}---{format_time(end_ms)}",
        "url": f"/watch/{quote(video_id, safe='')}?t={start_ms // 1000}",
        "start_ms": start_ms,
        "end_ms": end_ms,
    }


if __name__ == "__main__":
    citation = build_citation(
        {"video_id": "lesson-1", "start_ms": 738000, "end_ms": 766000},
        {"lesson-1"},
    )
    assert citation["label"] == "00:12:18---00:12:46"

引用区间不宜只覆盖一个瞬时帧。文字问题可使用相关字幕的最小包围区间,再在两侧增加少量播放缓冲;视觉事件则包含事件开始到结束。缓冲后的范围不能越过视频边界。多个事实来自不同时间段时应分别引用,不要为了界面简洁把不连续片段合成一个巨大区间。

服务端在返回前进行证据校验:引用编号是否存在、用户是否仍有视频权限、区间是否位于媒体时长内、答案中的关键事实是否至少关联一条证据。模型若输出未知编号,应该丢弃该引用并触发一次受限修复,仍失败则降级为"找到相关片段但无法生成可靠摘要"。

13. 评测:不要只测回答像不像

视频 RAG 至少分四层评测。第一层是媒体解析,检查时长、音轨、旋转信息和可解码性;第二层是时间轴,测量镜头边界和字幕时间偏差;第三层是检索,判断正确证据是否进入前若干名;第四层才是答案忠实度和引用可用性。只让评审模型给最终答案打分,会掩盖"答案碰巧对但引用错误"的问题。

构建评测集时,每个问题人工标注一个或多个有效时间区间,并标注需要语音、画面还是两者。检索命中不能只看 Chunk ID,因为不同切分版本会改变编号;可以计算召回区间与人工区间的交并比,也可以规定命中点是否落入容忍窗口。对"哪一段"问题,时间误差是核心指标;对"整节课总结",则更关注覆盖率和来源多样性。

建议记录 Recall@K、首个正确证据排名、时间区间 IoU、引用点击正确率、无证据拒答率和权限过滤正确率。还要按视频类型分桶:PPT 课程、屏幕录制、访谈和高频剪辑片的最佳参数往往不同。每次更换 ASR、视觉描述模型或切分阈值,都在同一固定集上回归,避免某一类提升掩盖另一类退化。

14. 常见失败模式与定位顺序

如果回答内容相关但跳转晚了几秒,先检查 ASR 原始时间戳,再检查转码是否改变起始时间,最后才调引用缓冲。直接统一减两秒可能让当前视频看似修好,却破坏其他来源。如果越到结尾偏差越大,重点检查音频采样率和可变帧率;如果偏差恒定,则更像起始时间或剪辑偏移。

如果同一答案返回很多相邻片段,检查 Chunk 重叠和融合去重,不能简单降低 top-k。如果画面问题总被字幕结果挤掉,检查查询路由、双路分数归一化和视觉描述质量。如果屏幕文字搜不到,先确认关键帧是否覆盖目标页面,再看 OCR;目标帧根本没抽到时,换更强 OCR 没有意义。

如果短视频效果好、长视频效果差,通常不是模型"忘了",而是切分、批处理失败恢复或索引版本出现问题。核对每一阶段的片段数量、覆盖总时长和失败列表。离线管道应该允许从某阶段断点重跑,并以源摘要和配置摘要作为缓存键,不能把旧转写与新镜头边界静默拼接。

15. 错误处理与可恢复入库

上传成功不等于入库成功。任务状态至少区分 received、probing、transcribing、vision_processing、indexing、ready 和 failed。每一阶段记录开始时间、结束时间、输入版本和稳定错误码。用户只有在状态为 ready 时才能检索,避免搜索到半部视频。

媒体损坏、无音轨和无画面应分别处理。没有音轨的产品演示仍能建立视觉索引,不应整体失败;纯音频文件则跳过镜头和关键帧。单个视觉帧处理失败可以记录并继续,但整段时间没有任何文字或视觉证据时要暴露覆盖缺口。外部模型限流使用带抖动退避,超过截止时间后任务进入可重试失败,不能无限占用 Worker。

幂等性依靠"源摘要 + 管道版本"唯一键。重复上传相同内容可以复用派生结果,但前提是租户和权限隔离正确;跨租户只因哈希相同就共享帧或字幕,会造成数据泄露。索引发布采用新版本构建、校验、切换别名的顺序,失败时保留上一个可用版本。

16. 安全、版权与隐私边界

视频文件是不可信输入。FFmpeg、解码库和图像处理组件应运行在受限容器中,限制 CPU、内存、磁盘、处理时长和输出大小,禁止访问内部网络,并及时更新安全版本。不能相信扩展名或客户端声明的 MIME 类型,要探测真实容器和轨道;压缩炸弹式媒体、超高分辨率和异常时长都应在入队前拒绝。

远程 URL 抓取要防 SSRF,只允许批准协议,解析并阻断内网、环回和云元数据地址,限制重定向并在每次跳转后重新校验。播放器链接必须做资源级授权,不能把对象存储永久公开地址直接交给浏览器。签名链接设置短有效期,并避免在分析日志、Referer 和错误信息中泄露。

语音、人脸、屏幕内容可能包含个人信息和商业秘密。入库前明确合法依据、保留期限和允许使用的模型区域;外部视觉或 ASR 服务只发送业务允许的数据。用户删除或撤权后,原件、帧、字幕、向量、缓存和评测样本都要按数据血缘清理。模型输出不能替代版权许可,能够技术解析并不意味着可以索引和再分发。

17. 成本与性能优化顺序

第一步不是换更小模型,而是减少无效处理。用源摘要避免重复入库,对相似关键帧去重,纯字幕问题不调用视觉模型,低价值片尾和长静音按规则过滤。第二步才是批处理:ASR 按 GPU 显存安排批次,图像嵌入批量计算,向量写入使用批量接口。第三步根据评测决定模型大小,不能只比较每小时价格。

在线延迟可拆成查询分析、双路召回、重排和生成。先记录每段耗时,再优化真正瓶颈。热视频的检索结果可以缓存,但缓存键必须包含索引版本、用户权限摘要和查询规范化结果;否则撤权后仍可能命中旧结果。生成答案的缓存同样要绑定证据版本。

容量规划以"每小时视频产生多少字幕、关键帧和向量"为单位。高频剪辑视频的镜头数可能远高于课堂录像,不能只按文件大小估算。为每种内容类型统计分位数,并对异常镜头数设置告警,既能发现成本风险,也能发现检测参数失控。

18. 适用边界与演进路线

第一版应先覆盖一种主要视频类型和少量真实问题,跑通时间轴、双路召回和可点击引用。确认字幕问题可靠后,再加入视觉描述;确认离线版本治理稳定后,再做增量更新和分布式任务。过早加入复杂知识图谱、多 Agent 编排和实时直播,只会让最基础的时间引用更难排查。

对于一分钟以内、内容单一的视频,直接转写加少量帧就够了;对只检索台词的播客,场景检测和视觉索引属于多余成本;对需要识别连续动作的安防或体育场景,关键帧摘要会丢失运动信息,应使用时序视觉模型并接受更高标注成本。视频 RAG 是证据检索系统,不是通用视频理解的替代品。

19. 专有名词与字幕质量治理

企业视频最容易错的往往不是普通句子,而是产品名、工单号、缩写和数字。ASR 把"Sentinel"识别成相近中文词后,语义向量仍可能召回相关主题,但用户按精确型号搜索就会失败。可以从课程标题、产品词典和已批准文档中构建术语表,对转写结果做受约束纠错。纠错记录必须包含原文、修正值、规则版本和置信度,不能直接覆盖原始证据。

数字需要单独校验。"十五毫秒"和"五十毫秒"只差一个音节,却可能改变技术结论。若画面同时出现配置值,可以把 OCR 与字幕作为两条独立证据;二者冲突时展示原片段并提示人工确认,不让语言模型自行选择更顺眼的答案。涉及金额、剂量、日期和安全阈值时,应设置更高的拒答门槛。

质量抽检应按错误风险分层,而不是随机听几分钟。优先检查低信噪比、多人重叠、置信异常、术语密集和 OCR 与语音冲突的片段。人工修订形成的新版本可以改进后续检索,但已发布回答仍应保留当时使用的字幕版本,避免审计时看到一份被事后修改的证据。

说话人标签同样不能只凭模型结果落库为真实姓名。分离模型通常输出"speaker_0",将它映射到具体人员需要片头介绍、会议名单或人工确认。未经确认时,回答使用"第一位讲者"等中性称呼。身份一旦涉及权限、绩效或责任判断,更不能依靠声纹自动推断。

20. 跨视频搜索与版本更新

单视频检索跑通后,跨课程搜索会带来新的排序问题。短视频的高相似片段容易压过长课程,重复发布的同一内容又会占满结果。融合排序时可以先在每个视频内部取少量候选,再跨视频重排,并按源摘要或近重复检测合并同内容版本。结果页仍显示每个来源的标题、发布日期和时间区间,让用户判断哪个版本有效。

培训内容会过期。元数据应包含生效时间、失效时间和替代版本,查询默认优先当前有效内容,但历史问题允许显式查看旧版本。不能只把旧向量删掉,因为审计可能需要还原当时制度;更合理的是将其从普通检索范围移出,保留受控历史索引和清晰的"已失效"标记。

源视频只改了字幕或补了一段片尾时,可以重用未变化片段的派生数据,但复用依据必须是内容摘要和时间映射,不能按场景序号猜测。新旧时间轴不同,旧引用链接需要继续指向旧媒体或通过明确映射跳转。若无法可靠映射,就保留旧版本,不应静默把历史引用改到相似但并非同一证据的位置。

跨视频答案还要限制来源数量和冲突处理。两门课程给出不同参数时,生成层应分别陈述并标注日期,而不是平均成一个值。检索系统负责把证据找全,业务规则负责决定哪份制度更权威,语言模型不应凭措辞强弱替组织做版本裁决。

小结

视频 RAG 的主线不是"选哪个多模态模型",而是把所有派生信息放回同一条可验证时间轴。镜头边界提供视觉结构,Whisper 分段提供语音语义,关键帧和 OCR 补足画面信息,时间交集把这些证据对齐,双路检索再按问题类型融合。最终引用由服务端根据可信元数据生成,而不是让模型凭空写时间戳。

当系统能够回答"证据来自哪个视频、哪段时间、由哪个管道版本生成、当前用户是否有权查看"时,才算完成了从视频搜索到视频 RAG 的跨越。之后无论替换 ASR、视觉模型还是向量数据库,都可以通过固定评测集验证收益,而不必用几个演示问题猜测效果。

参考资料

相关推荐
Dovis(誓平步青云)1 小时前
导览音频切换太快,旧讲解不能覆盖新展品
android·前端·javascript·ecmascript·音视频·宠物
陈天伟教授1 小时前
学术写作设计的 AI 助手 - Jenni AI
人工智能
海盗12341 小时前
AI 新闻日报 2026-10-05:三周重构 30 万行 / macOS 给智能体加权限锁 / 具身订单结构被摆上台面
人工智能·macos·重构·机器人·人工智能aigc
deepseek231 小时前
PDF 涂黑失效拆解:谷歌数据中心 52.65 兆瓦与 7.65 亿加仑的账本,被一次复制粘贴揭开
人工智能·pdf·数据中心
Dawson Zhu1 小时前
《Agentic Design Patterns》第 3 章导读:并行化(Parallelization)
人工智能·语言模型·架构·aigc·agi
长三角智造观察3 小时前
中小制造ERP+MES选型避坑:对接集成VS原生一体化,深度对比TCO与落地痛点
大数据·人工智能·制造
一水鉴天9 小时前
映射、哈希表与哈斯图:计算机科学的三种基线 20261003(元宝)
开发语言·人工智能
198******126349 小时前
2026 企业 AI 办公产品选型指南:从场景匹配判断工具价值
人工智能
玫瑰互动GEO10 小时前
GEO优化学习九级模型:开发者从认知层切入
人工智能·ai·ai搜索·gem·生成式引擎优化·gem优化