ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多模态RAG视频知识库构建:数据清洗全流程实战经验

多模态RAG视频知识库构建:数据清洗全流程实战经验

一直以来我都觉得,纯文本 RAG 已经很难伺候了,直到我接了一个要把视频变成可检索知识库的需求,才意识到真正麻烦的事还在后头。

视频知识库的核心难点不在“建库”,而在“清洗”。一段 30 分钟的产品演示视频里,既有人声讲解,也有屏幕上的操作步骤,还有图片、图表、弹窗提示。你不可能把一个视频文件直接丢给向量库——它不知道该怎么理解。所以我这次基于 LangChain 做了个 Multimodal RAG 的完整链路,重点用“数据清洗全流程”来把视频里的杂音、重复帧、无效画面全部过滤掉,最后得到一个能按内容语义检索的视频知识库。

这篇博文我会把自己实际踩过的坑、每一步为什么要这么设计、用了什么参数、跑出来是什么样的结果,都整理出来。适合正在做多模态知识库、视频理解、或者被 RAG 数据质量折腾到头秃的同学们。

1. 为什么视频知识库不能直接“嵌入”

很多同学第一次接触视频 RAG 时,第一反应都是:把视频切成帧,然后跑一个多模态模型把它变成向量,再进向量库。听起来很顺,但实际跑一次就知道问题在哪。

1.1 视频里藏着的其实有四种信号

视频不是一个单一模态,它至少包含四种信息流:

  • 语音:讲解的人说了什么
  • 画面:屏幕上长什么样,有没有操作步骤
  • 字幕/弹幕:视频里自带的文字信息
  • 音频中的非语音信息:比如背景音、提示音、警告声

如果只做帧嵌入,语音信息就丢了;如果只做语音转写,画面里的操作步骤又没了;如果把这两种结果简单拼接,又会造成大量冗余——同一段内容被重复编码,检索时不光浪费存储,还会拉低相关性的精度。

所以多模态 RAG 的第一步不是选模型,而是建立一个“多通道信息提取”的流水线。每个通道提取一种模态的信息,然后再有策略地融合。

1.2 检索效果差的根因往往是“脏数据”

我最初用一版最粗暴的方案:抽帧 + Whisper 转录 + 直接拼接文本 + 丢进向量库。测试的时候出了一堆让人哭笑不得的结果:

  • 用户问“如何导出报表”,检索出的片段居然是一个黑屏过渡帧,只有几行模糊的菜单文字
  • 用户问“权限设置”,视频里明明讲了很久,却因为转录文本和画面文本交错混排,向量距离被拉开,命中的反而是一段不相关的开场白
  • 视频时长很长,前半段和后半段画面几乎一样,造成大量重复向量,检索结果被一整个冗余片段刷屏

这些问题根本不是模型不够好,而是数据在入库之前没被洗干净。数据清洗不是 RAG 流程里可以“以后再说”的环节——它就是工程主体。

2. 整体链路设计与三个信息通道

我最终设计的流水线分三层:抽取层、清洗层、入库层。每条视频进来之后,都走三条独立通道,最后在清洗层再汇合。

2.1 第一条通道:音频转录

这一步用 Whisper 处理音轨。我可以直接调用 transformers 里的 Whisper 模型,但工程上更稳的方式是先用 ffmpeg 把音轨单独抽出来。

ffmpeg -i input.mp4 -vn -acodec pcm_s16le -ar 16000 -ac 1 audio.wav

这里有几个参数值得解释:

  • -ar 16000:把采样率固定为 16kHz。Whisper 内部会做重采样,但你预先做一次可以避免某些模型版本对真实采样率的误判
  • -ac 1:强制单声道。多声道音轨会让转录结果变差,尤其是左右声道不一致的录制视频
  • -vn:丢掉视频流,只保留音频

然后加载 Whisper 做转录。如果你用的是openai/whisper-large-v3,记得带上return_timestamps=True,因为后续做时间戳对齐的时候,没这玩意儿就白搭。

from transformers import pipeline transcriber = pipeline( "automatic-speech-recognition", model="openai/whisper-large-v3", return_timestamps=True, device=0 ) result = transcriber("audio.wav") segments = result["chunks"]

Whisper 输出的 chunks 每段都会带起始时间和结束时间。这一步的产物,可以理解成“带时间戳的纯文本段落”。

2.2 第二条通道:画面抽帧与 OCR

画面处理是所有通道里最容易出错的。第一版我每秒钟抽 1 帧,结果一个 25 分钟的视频抽了 1500 张图,里面一半都是视觉信息完全雷同的连续帧,浪费存储不说,检索时还引入大量噪声。

后来我把抽帧逻辑改成“混帧率动态抽帧 + 感知哈希去重”,这个细节我会放在第 3 节专门讲。这里只说通道本身:

ffmpeg -i input.mp4 -vf "fps=1,scale=1280:-1" frames/%06d.jpg

抽出来的帧交给 OCR 工具做文字识别。实际项目里我用的是 PaddleOCR,配合lang_codes参数支持中英文混排。OCR 的输出是一堆带坐标的文本框,但我们入库阶段一般只保留文本内容和时间戳,坐标信息在后面做版式判断时会用到。

from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) frame_ocrs = [] for img_path in selected_frames: result = ocr.ocr(img_path, cls=True) text_parts = [] for line in result: for word_info in line: text_parts.append(word_info[1][0]) frame_ocrs.append(" ".join(text_parts))

2.3 第三条通道:场景切分与关键帧抽取

如果视频是一个培训录屏,屏幕连续滚动的变化非常频繁;如果是演讲录播,又会出现长时间静止的画面。对这两种内容,抽帧策略应该是完全不同的。

我倾向于用 PySceneDetect,基于内容变化检测做场景切分,它识别出的是“场景边界”,而不是平均分布的时间点。

from scenedetect import detect, ContentDetector, split_video_ffmpeg scene_list = detect("input.mp4", ContentDetector(threshold=27.0))

这个threshold=27.0是手感值。调低了会把同一个画面的微小变化切碎;调高了会把演讲中镜头缓慢移动的片段当成一个整体场景,导致中间关键画面丢失。我的经验是,PPT 类技术分享用 25-30,真人出镜的口播类视频可以放到 40 左右。

场景切分产物的价值在于:它给“一个完整语义片段”提供了一种天然的边界标记,后续做文本段落切分的时候,可以直接拿来用。

3. 数据清洗全流程的四个关键环节

如果说抽取层决定“有没有素材”,清洗层就决定“素材能不能用”。我在这个环节卡了最多的调试时间。

3.1 基于感知哈希的重复帧过滤

连续帧去重,最常用的方法是感知哈希。把每张图片缩到 8x8 灰度图,算 64 位哈希值,两张图的汉明距离小于某个阈值就视为相似。

import imagehash from PIL import Image def is_duplicate(img1_path, img2_path, threshold=5): hash1 = imagehash.phash(Image.open(img1_path)) hash2 = imagehash.phash(Image.open(img2_path)) return hash1 - hash2 <= threshold

这里有个容易被忽略的点:汉明距离的阈值不能拍脑袋定。不同视频的分辨率、噪声情况差异很大,我建议先在每个视频上抽样 50 帧,算出两两之间的平均距离分布,再按分布去设阈值。比如平均距离是 4,那阈值取 8 可能过宽,会把所有帧全部滤成“重复”;如果平均距离是 2,阈值 5 又会让很多确实不同的帧被误判。

3.2 黑屏帧、绿屏帧和纯色帧的过滤

这类问题在录屏视频里极其常见:前后两段之间有短暂的黑屏,视频编码也会在开头结尾生成黑帧。它们没有信息量,还会污染 OCR 的结果。

判断方式非常简单,计算画面亮度方差:

import numpy as np from PIL import Image def is_blank_frame(image_path, std_threshold=12.0): img = np.array(Image.open(image_path).convert("L")) return img.std() < std_threshold

纯色帧灰度图的亮度标准差通常会低于 10,而有内容的画面一般至少 20 多。这个阈值在绝大多数正常视频上都很稳。

3.3 转录文本的时间戳对齐

Whisper 输出的分段,时间戳精度通常是几十毫秒级别。问题在于,你想把“某个 OCR 识别出的屏幕文字”和“某段语音说出的内容”对应起来时,两者往往不在同一秒。

我的做法是:以 Whisper 的段落时间戳为主轴,对每一段语音,找到时间重叠范围内出现的 OCR 文本。如果重叠窗口内没有任何 OCR 文本,就把该段落标记为“纯讲解”,不强制去匹配画面。

时间戳对齐这件事,听起来很简单,但实际上有一个特别坑的细节:Whisper 返回的时间戳是相对整个音频文件而言的,可如果你的视频里有一小段无声区间,Whisper 会直接把那段跳过,后面的时间戳整体后移。解决办法是做一次偏移修正:把 Whisper 输出的段落起始时间,和 ffmpeg 抽帧时记录的帧时间戳做一次线性映射,而不是直接信模型。

3.4 冗余文本压缩

不少视频都有开场白、口头禅、重复强调的段落。比如“大家好,欢迎来到今天的分享,我是某某”,这类内容每句话都带着“嗯”“然后”“对吧”之类的语气词。

直接拿这些脏文本去做 embedding,效果很差,因为嵌入模型会把这些高频语气词当作语义的一部分。我用了两层处理:

  • 第一层用简单的规则过滤:去掉重复的换行、连续空格、纯语气词列表
  • 第二层用一个轻量的 LLM 对每条转录文本块做短句压缩,把“我想跟大家分享一下关于权限这方面的一些经验”压缩成“分享权限经验”

第二层如果用大模型,成本不低,所以我只对长度超过 200 字的段落做压缩,短段落直接跳过。

4. 分块、嵌入与向量存储的策略

清洗完的数据,必须切成适合嵌入和检索的块。视频知识库的分块策略,比纯文本 RAG 更复杂,因为每个文本块还挂着时间戳、所属场景、来源通道等结构化属性。

4.1 按“语义单元”而不是按固定字符数分块

纯文本 RAG 常用的split_text(text, chunk_size=500)这种固定分块方式,用在视频转录文本上效果很差。原因是口语文本没有严格标点,固定切分经常会断在一句话的中间。

我按“场景”来分组文本。每个场景内部的转录文本合并成一个候选块;如果场景的文本太长,再按句子边界二级切分。LangChain 里的RecursiveCharacterTextSplitter可以用来兜底:

from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=80, separators=["\n\n", "。", "!", "?", ". ", "! ", "? ", ",", ","], )

这里chunk_overlap=80很重要。视频口语的语义经常跨段延续,比如“这里我解释一下”连着后一段的“为什么要清洗”,如果没有 overlap,检索时可能只命中前半句,导致答案不完整。

4.2 多模态 embedding 还是拼接后再 embedding

这个问题的答案取决于你的向量库和检索目标。我在项目里试过两条路线:

  • 路线 A:用 CLIP 类模型单独对帧做图像 embedding,用文本模型对转录文本做 embedding,最后在检索阶段把两种相似度做加权融合
  • 路线 B:把 OCR 文本 + 转录文本拼接成一段文字,直接过文本 embedding 模型

路线 A 的优势是能检索“用户脑子里想到的画面”,比如用户问“中间那张红色的架构图”,可以通过 CLIP 图像向量的语义相似度匹配到正确的帧。路线 B 的优势是工程简单,检索结果稳定,但完全没有利用视频的视觉语义。

我的最终方案是路线 A 和路线 B 的折中:每条片段都保存三份内容,一份文本向量(来自转录+OCR 拼接),一份图像向量(来自片段代表性帧),一份元数据 JSON。检索阶段分别算文本相关度和图像相关度,然后加权融合。

4.3 LangChain 在这里到底起了什么作用

LangChain 在这个项目里不是最核心的模型层,而是把组件“串起来”的编排层。它提供的核心能力是:

  • 标准化的 Document 抽象:所有清洗后的片段,不管来自音频还是画面,统一包装成Document对象
  • retriever 接口:可以把向量检索、BM25 检索、重排序串成一条链
  • 快捷的 VectorStore 接入:不同向量库之间的切换成本很低

我其实在早期写过一版纯手写流程,也能跑,但后面加需求变得很痛苦,比如要加一个“按时间过滤候选片段”的逻辑,手写就得自己管理一堆全局变量。换成 LangChain 之后,把这些逻辑封装成自定义 retriever,复用性明显好很多。

4.4 元数据驱动的动态过滤

视频知识库一个独有的需求是:用户可能只想查“第 5-10 分钟”的内容,或者只想搜“屏幕上的操作步骤”。这种检索条件没法靠语义向量解决,必须用元数据过滤。

我在每条 Document 上挂的元数据包括:

{ "video_id": "demo_001", "scene_id": 12, "start_time": 653.2, "end_time": 671.8, "source_channel": "audio_transcript", "speaker": "presenter", "has_ocr_text": true, "frame_path": "frames/scene12_key.jpg" }

LangChain 里可以通过SelfQueryRetriever把用户的自然语言查询拆成“过滤条件 + 语义查询”两部分,比如用户问“15 分钟之后那一段关于权限配置的讲解”,它会自动把start_time > 900作为过滤条件,把“权限配置”作为语义查询。这样检索精度比单靠向量高出一截。

4.5 向量库的选择与实测

我对比了常见的几种方案:本地用 Chroma、分布式用 Milvus、还有云服务。视频知识库的向量量级一般不高,几千到几万条之间,用 Chroma 完全够用。

但如果你的场景是几十万条视频片段、多人协作、持续更新,那一定得换 Milvus。原因不光是性能,更是因为 Milvus 的标量过滤和向量检索是原生一体的,而 Chroma 在过滤条件复杂时,性能衰减很厉害。

5. 检索链的设计与踩坑实录

清洗和入库只是前半段,检索效果才是最终评判标准。这一部分我总结了我实际遇到的几个典型问题,和对应的解决方式。

5.1 混合检索比纯向量检索稳

视频转录文本有个特点:口语化严重,同义词多。向量检索对这种场景很友好,但某些专有名词,比如软件名、接口名、版本号,向量模型不一定学得好。

我在检索链里同时接了 BM25 关键词检索和向量检索,把两路结果做倒数排名融合。LangChain 里可以直接用EnsembleRetriever:

from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever bm25_retriever = BM25Retriever.from_documents(all_docs) bm25_retriever.k = 20 vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 20}) ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever], weights=[0.3, 0.7], )

权重分配上,我自己的数据集里向量占 0.7 效果最好。但如果你视频里的专有名词特别多、口语又少,可以试试把 BM25 权重拉到 0.4 甚至 0.5。

5.2 重排序的必要性和代价

召回 20 条并不代表 20 条都相关。尤其是帧检索结果,经常出现画面相似但语义完全不同的情况。我接了重排序模型 reranker,对召回结果重新排序后只保留前 5 条。

在实践里,重排序对检索质量提升非常明显,但成本不低。我的策略是:只有向量检索和 BM25 融合后的结果超过 10 条时才跑重排序,少于 10 条直接全部用于生成。

5.3 一个特别典型的问题:检索到的时间戳错位

有一次我跑完整个流程,发现检索出来的“正确片段”总是比预期晚 3-5 秒。排查半天,问题出在 Whisper 的转录时间戳和 ffmpeg 抽帧时间戳之间没做对齐。

我的修复方法是做一个时间戳校准:用 ffmpeg 提取视频的原始音频时间轴,和 Whisper 输出里的时间轴做最小二乘拟合。听起来复杂,但实际操作只需要提取几个关键锚点就能算出一个线性偏移量。

这个问题必须在清洗阶段解决,如果等到检索阶段再修,你就得重新回灌所有向量,成本非常高。

5.4 帧检索的内容漂移问题

帧检索另一个常见问题是“内容漂移”。用户搜“设置界面截图”,命中的却是设置界面的前一帧,画面上只有半个设置窗口。原因是那张帧和完整设置界面的整体相似度较高,但实际关键信息缺失。

解决办法是给关键帧做一次置信度筛选:OCR 识别出来的文字数量太少,或者画面中心区域内容占比过低的帧,即使哈希去重通过了,也会被标记为“低质量帧”,不让它进入图像向量库。

5.5 长视频的分段检索方案

如果是超过 1 小时的视频,把所有片段一次性拿出来做重排序,响应时间会很难看。我用了一个滑动窗口策略:在时间轴上每 60 秒开一个窗口,每个窗口内最多选 3 个候选片段,然后在窗口级别做一次初筛,再聚焦到具体片段。

这个方案的效果是把一次检索的耗时从 3 秒左右压到了 800 毫秒以内,同时没有明显损失准确率。代价是需要预计算时间窗口索引,这个在入库阶段做一次即可。

6. 我的经验总结和避坑清单

走到这一步,整条链路算是跑通了。最后整理一份踩坑清单,每个点都是我实际遇到过并且在代码里修过的。

6.1 数据清洗阶段

  • 不要信任任何单一模型的输出。Whisper 转录会漏词、OCR 会误识别,两种结果合在一起之前,必须各自做一遍质量过滤
  • 感知哈希去重时,阈值必须按视频动态设置,不能全局写死
  • 黑屏帧、纯色帧的过滤要放在 OCR 之前,否则 OCR 会在垃圾帧上浪费算力,还可能产生假文本
  • 时间戳对齐一定要做,而且要在清洗阶段做,不要拖到检索阶段

6.2 分块与入库阶段

  • 口语文本分块按场景优先,固定字符数切分放最后兜底
  • 每个片段至少要保留三段信息:纯文本段、代表性帧(或者帧路径)、完整元数据
  • 如果预算紧张,OCR 文本块可以先用规则压缩一遍,体积能减少 30% 左右,检索效果反而更好

6.3 检索阶段

  • 混合检索是标配,纯向量不适合口语化的转录文本
  • 重排序是质量保障,但要控制调用频率
  • 用户查询里的时间和场景条件,要走元数据过滤,别指望向量模型自己理解

6.4 最后分享一个小细节

我接入 LangChain 的最大收获,不是它某个具体的功能,而是它强制我把整个流程拆成了标准化的组件。最开始手工写流水线时,每个模块紧密耦合,想改一个清洗规则就得动上层的代码。后来我把所有清洗模块都接成了 LangChain 的DocumentTransformer接口,改规则变成了改配置,跑实验快了很多。

如果你也在做类似的项目,我从实际经验里给你一个优先级建议:先花 70% 的时间把数据清洗做好,再用 20% 的时间设计分块和元数据策略,最后 10% 给模型和场景调优。很多项目做到一半发现效果差,回头去补数据清洗,其实那不是“补”,而是地基一开始就没打牢。

视频知识库这个方向,真正的门槛不在于用什么模型、跑什么框架,而在于你能不能从一堆原始、混乱的媒体文件里,稳定地构建出干净、结构化、可检索的语义单元。想清楚这件事,再回头看 LangChain 和 Multimodal RAG,它们就只是顺手就能用的工具了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进