跳到主要内容

索引管线:一段视频如何变成两套索引

本章讲 insert_video 的全过程:原始 mp4 进去,出来的是「文字知识图谱」+「视频片段向量库」两套磁盘索引。查询章节用到的所有数据,都在这里造出来。

2.1 为什么要先「蒸馏」

它要解决的小问题: 长视频没法整体喂给模型,也没法整段做向量检索(一段 1 小时的视频是一个向量,毫无分辨率)。

思路: 把视频切成固定长度的小段(默认 30 秒,video_segment_length=30videorag.py:67),每段独立处理。段是 VideoRAG 里检索和引用的最小单位——后面所有时间戳引用都精确到「哪个视频的哪一段」。

2.2 insert_video 的七步

主循环在 videorag.py:202insert_video,对列表里每个视频依次跑:

Step0 查重:video_name 已在 video_segments 就跳过 videorag.py:206
Step1 split_video:切 30s 段、抽音轨、算采样帧时间 videorag.py:215
Step2 speech_to_text:Whisper 给每段转写 videorag.py:224
Step3 并行两进程:保存视频段文件 ‖ 视觉语言模型生成字幕 videorag.py:236
Step4 merge_segment_information:把「字幕+转写」拼成段文本 videorag.py:275
Step5 video_segment_feature_vdb.upsert:ImageBind 编码每段 videorag.py:287
Step6 删 _cache 临时段文件 videorag.py:294
Step7 存盘当前进度 videorag.py:299
之后 ainsert(video_segments):分块 → 抽实体 → 进图谱 videorag.py:301

Step1 切片:不只是切,还预先算好「采样哪些帧」

split_videosplit.py:10)用 moviepy 按 segment_length 切,并为每段算 frame_times——在段内等距取 num_frames_per_segment 个时间点:

# 示意,源自 split.py:42 — 段内等距采样帧时刻
frame_times = np.linspace(0, subvideo_length, num_frames_per_segment, endpoint=False)
frame_times += start # 平移到全局时间轴

重点看两处巧思(都在 split.py):

  • 段命名带全局信息: 段名是 {unique_timestamp}-{segment_index}-{start}-{end}split.py:45),起止秒数直接编进文件名——后面查询时靠它还原时间戳。
  • 尾段合并: 若最后一段不足 5 秒,并进前一段(split.py:31),避免碎片。
  • 音轨可缺失: 抽音失败只 warning 不报错(split.py:54),因为有的视频没声音;对应转写会是空串。

Step2 转写:faster-whisper,带时间戳的字幕

speech_to_textasr.py:8)对每段音频跑本地 faster-distil-whisper-large-v3,把结果拼成带行内时间戳的文本:

# 示意,源自 asr.py:26 — 每段转写拼成带时间的多行文本
result += "[%.2fs -> %.2fs] %s\n" % (segment.start, segment.end, segment.text)

没有音频文件时直接给空字符串(asr.py:20)。

Step3 并行:保存片段 ‖ 生成字幕(关键性能设计)

这是管线里最重的两件事,VideoRAG 用 multiprocessing.Process 并行跑(videorag.py:262):

  • 进程 A saving_video_segmentssplit.py:61):把每段重新编码成独立 mp4(供后续 ImageBind 读取)。
  • 进程 B segment_captioncaption.py:17):用 MiniCPM-V-2_6 视觉语言模型,把采样帧 + 该段字幕一起喂进去,让它写一句画面描述。

字幕的提示词很简单,但故意把转写当上下文塞给视觉模型(caption.py:28):

# 示意,源自 caption.py:28 — 让视觉模型「看着帧、参考字幕」写描述
query = f"The transcript of the current video:\n{segment_transcript}.\n" \
"Now provide a description (caption) of the video in English."
msgs = [{'role': 'user', 'content': video_frames + [query]}]

坑/细节: 两个子进程通过 multiprocessing.Manager().dict() 和一个 error_queue 回传结果与异常(videorag.py:233)。任一子进程抛错,主进程 join 后从 error_queue 取出、写 error_log_videorag.txt、再 raise RuntimeErrorvideorag.py:268)——这样子进程的崩溃不会被默默吞掉。也正因为用了 spawn 启动方式,example 脚本必须 multiprocessing.set_start_method('spawn')process_videos_deepseek.py:19)。

Step4 合并:段文本 = 字幕 + 转写

merge_segment_informationcaption.py:45)把每段拼成统一结构。注意 content 字段是字幕在前、转写在后,这是后面分块和抽实体的输入:

# 示意,源自 caption.py:51 — 一段的「内容」既含画面也含语音
inserting_segments[index]["content"] = f"Caption:\n{captions[index]}\nTranscript:\n{transcripts[index]}\n\n"
inserting_segments[index]["transcript"] = transcripts[index]
inserting_segments[index]["time"] = '-'.join(segment_name.split('-')[-2:]) # 从段名取回 start-end

Step5 编码:ImageBind 把「画面」变成可检索向量

NanoVectorDBVideoSegmentStorage.upsertvdb_nanovectordb.py:93)加载 imagebind_huge,对每段 mp4 编码出 1024 维视觉向量(video_embedding_dim=1024videorag.py:74),存进片段向量库。这是视觉通道的索引来源。

ImageBind 的妙处:它把视频、文本映射到同一个向量空间feature.py:10 编码视频、feature.py:20 编码文本查询都用同一个 embedder)。所以查询时可以用「一句文字」直接去和「视频段」算相似度——这就是视觉检索能成立的根基。

之后:ainsert 把段文本送进图谱

七步跑完,insert_video 末尾一次性调 ainsert(self.video_segments._data)videorag.py:301)。ainsertvideorag.py:344)做三件事:

  1. get_chunks 把各段 content 按 token 预算打包成 chunk(见下)。
  2. 若开了 naive RAG,把 chunk 存进 chunks_vdb(文字向量库,videorag.py:365)。
  3. extract_entities 抽实体/关系建图谱(详见 03-knowledge-graph.md)。

2.3 视频感知的分块(chunking_by_video_segments)

它要解决的小问题: 普通 RAG 按字数切块会切碎句子;但这里有天然边界——视频段

chunking_by_video_segments_op.py:68)的策略是「贪心打包段」:把连续的段往一个 chunk 里塞,直到 token 数超过 max_token_size(默认 chunk_token_size=1200)就开新 chunk。每个 chunk 记下它由哪些段组成:

# 示意,源自 _op.py:85 — 贪心:能塞下就并入当前 chunk,否则封口开新的
if len(chunk_token) + len(tokens) <= max_token_size:
chunk_token += tokens.copy()
chunk_segment_ids.append(doc_keys[index]) # 记住这块来自哪些段
else:
results.append({"content": ..., "video_segment_id": chunk_segment_ids, ...})
# 重置,用当前段作为新 chunk 起点

为什么 video_segment_id 这么重要: 它是「文字 chunk ↔ 视频段」的桥。查询时图谱通道命中某个 chunk,就能顺着这个字段反查到对应的视频段去精读(见 03-knowledge-graph.md_find_most_related_segments_from_entities)。

段文本的 doc_key 形如 {video_name}_{index}_op.py:165),这个「视频名_段号」格式贯穿全系统,查询时用 rsplit('_') 拆回视频名与段号(_op.py:656)。

代码地图

主题文件符号
索引主循环(七步)videorag/videorag.pyVideoRAG.insert_video
段文本送图谱videorag/videorag.pyVideoRAG.ainsert
切片 + 采样帧 + 抽音videorag/_videoutil/split.pysplit_video
保存片段文件(子进程)videorag/_videoutil/split.pysaving_video_segments
语音转写videorag/_videoutil/asr.pyspeech_to_text
视觉语言模型字幕(子进程)videorag/_videoutil/caption.pysegment_caption
合并段信息videorag/_videoutil/caption.pymerge_segment_information
视觉向量编码videorag/_videoutil/feature.pyencode_video_segments
视频感知分块videorag/_op.pychunking_by_video_segments, get_chunks
片段向量库videorag/_storage/vdb_nanovectordb.pyNanoVectorDBVideoSegmentStorage