这段时间在做视频知识库的时候我最大的感受是真正让项目推进不下去的往往不是 LangChain 链路不会写也不是向量检索调不通而是视频数据在进索引之前没人愿意认真清洗。这篇文章完整复盘一次基于 LangChain Multimodal RAG 的视频知识库构建过程重点放在数据清洗全流程上包括抽帧策略、语音转写、OCR 合并、去重、切分、双路索引和检索融合。项目针对的场景很具体几十个小时的内部培训视频录完就吃灰现在要求同事用自然语言提问系统能直接定位到视频里的对应片段并给出时间点。适合正在做企业内部知识库、课程平台、会议沉淀系统的人参考也适合刚接触多模态 RAG、想搞明白视频类数据到底怎么处理的人。1. 项目概述与整体设计1.1 视频检索的真实痛点先说清楚业务背景。这个项目的原始数据是过去三年积累的培训录像和产品演示视频总量大概四十个小时。以前的做法是人工写摘要、手动打标签视频一多根本维护不动找某个功能点时只能凭记忆翻进度条效率极低。最初想得比较简单把视频转成文字丢进向量库套一个问答链路不就行了真正做起来才发现视频和纯文本文档差距很大。第一视频里有大量信息在语音之外比如 UI 操作步骤、PPT 上的关键结论、流程图、代码截图转写文本根本覆盖不到。第二即使转写准确语音文本的口语化问题很严重直接做 embedding 效果会很差。第三用户要的是精确定位答案必须能落到某个时间戳而不是给一段没有出处的长文本。这些痛点决定了不能走纯文本 RAG 的老路必须把画面和声音一起纳入索引。1.2 为什么不能只用文本 RAG纯文本 RAG 的局限在这个场景下非常明显。我做过对比实验把 ASR 转写的文本直接切片后构建索引用户问创建项目时权限配置在哪个界面系统返回的片段大多是然后这里要注意权限设置这种口语化表述既没有界面信息也没有上下文依据。因为 ASR 转写天然丢失了视觉上下文说话人在讲这个按钮的时候这个指的是什么只有画面知道。这就是多模态 RAG 的核心价值视频知识库需要同时索引语音转写文本、OCR 识别出的屏幕文字、以及关键帧的图像向量检索时多路召回最后把结果融合到一起。也只是这样做了之后问答才能给出在第 12 分 30 秒的界面上权限配置位于右上角这类精确回答。1.3 多模态 RAG 的两种实现路线多模态 RAG 在技术选型上有两条路线。一种是把图像通过多模态大模型转成文字描述再统一走文本索引这种方式实现简单检索阶段只有一个向量空间但每张图都要调一次模型成本高而且描述一定会损失细节。另一种是双路索引文本走文本 embedding图像走图像 embedding分别存入向量库查询时用同一个 query 分别召回再用融合算法合并结果。视频知识库我推荐第二种原因在于视频帧数量大且很多界面图需要保留像素级特征直接转文字描述会丢掉太多信息。LangChain 在这里承担的是编排层把抽帧、转写、OCR、向量存储、检索、重排、回答生成串成一条流水线。它本身不解决数据处理问题但它提供的 Document、Retriever、Chain 抽象能让我们少写很多胶水代码。2. 整体架构与数据流转设计2.1 从原始视频到知识库的完整链路整个数据流可以分成六个阶段。第一步是视频预处理包括统一格式、截取有效段落、抽帧第二步是 ASR 语音转写拿到带时间戳的文本第三步是 OCR 识别关键帧中的文字第四步是数据清洗覆盖去重、纠错、格式化、无效片段过滤第五步是建立双路索引文本向量和图像向量分别入库第六步是检索融合与问答。这六个阶段里最容易被低估的是清洗。很多人以为清洗就是去掉空行、合并缩进实际在视频数据里清洗要处理的是模糊帧、重复画面、ASR 错字、口语语气词、OCR 置信度、时间戳错位、语义重复段落任何一个环节偷懒都会在检索阶段放大成低质召回。2.2 数据清洗在流水线中的核心位置我画过一条数据质量曲线原始视频数据的有效信息密度低但清洗后的索引质量决定了 RAG 回答的天花板。如果送进索引的文本是脏的再好的重排模型也救不回来。这个项目里我甚至把清洗单独抽象成一个模块每一帧、每一条转写片段都带有清洗前后的日志记录。好处是可以回溯问题比如某个检索结果很差能快速定位是抽帧丢了内容还是转写错了词还是切分把语义截断了。架构上清洗模块的输出格式统一成{text, image, metadata, start_time, end_time}的结构化片段后面无论接 FAISS 还是接其他向量库都不受影响。2.3 清洗与切分的关系视频数据的切分和文本切分逻辑完全不同。普通文档按长度切分就行视频必须按语义边界切否则会切断一句话或者一段完整操作。我的做法是先以 ASR 的 segment 为单位每一个 segment 是一段完整语音再根据时间戳把 OCR 结果合并到对应的 segment 上最后如果单个 segment 太长超过 embedding 模型上限才做二级切分。这种语义优先、长度兜底的策略比直接按字符切分要稳定得多。切分时还要保留一个关键字段video_id 和 start_time这是后面做答案溯源的基础。3. 数据清洗全流程细节3.1 抽帧清洗先筛掉无效画面视频抽帧不是每隔几秒存一张图就完事。这个项目里我先按每秒 0.5 帧抽了一遍四小时的视频得到七千多张图其中相当一部分是纯色过渡帧、模糊帧、重复帧。如果全部做 OCR 和图像 embedding既浪费算力又污染索引。清洗第一步是清晰度过滤。我用 Laplacian 算子计算灰度图的方差方差低于 50 的帧直接丢掉。实测下来PPT 文字清晰的画面方差通常在一百以上而镜头切换过程中的模糊帧普遍低于三十。第二步是黑边裁剪很多录屏视频上下有黑边直接送入图像模型会浪费有效像素我用 OpenCV 做边缘检测把黑边裁掉再统一缩放到高清模型需要的尺寸。第三步是重复画面检测连续多帧画面如果像素差异小于阈值只保留时间戳最靠前的一帧。这样做下来七千张图留存率大概只有六成但送入索引的画面质量明显提升。import cv2 def is_blurry(frame, threshold50): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) score cv2.Laplacian(gray, cv2.CV_64F).var() return score threshold def clean_frames(video_path, interval_sec2.0): cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) interval_frames int(fps * interval_sec) valid_frames [] seen None idx 0 while True: ret, frame cap.read() if not ret: break if idx % interval_frames 0: if is_blurry(frame): idx 1 continue # 简单去重与上一张保留帧的像素差 if seen is not None: diff cv2.absdiff(seen, frame).mean() if diff 3: idx 1 continue seen frame valid_frames.append((idx / fps, frame)) idx 1 cap.release() return valid_frames这一版代码是示意写法实际项目里把相似度阈值和抽帧间隔都做成了配置项不同视频类型差异很大。3.2 ASR 转写文本的清洗ASR 模型用的是开源的 Whisper 系列中文识别效果可以接受但原始输出直接进索引会暴露一堆问题。最典型的是语音转写中带着大量口语词像嗯然后就是说我们来看一下这类填充词。我在清洗管线里维护了一个停用词表和规则集连续出现两次以上的语气词直接合并句首的然后/那么/所以说根据上下文判断能删则删。还要处理数字格式比如十二点五版本统一成12.5 版本百分之三十统一成30%。这些规则看似不起眼但对 embedding 影响很大向量模型对规范和噪声敏感干净文本的检索效果肉眼可见地提升。转写纠错是另一个重点。中文语音识别经常出现同音错字比如把部署写成部属把权限写成全选。我建了一个领域词典把项目里高频出现的产品名、术语、缩写都收进去转写后做一轮词典替换。这个词典只有几百个词却解决了大量检索不中的问题。清洗后的每一条转写片段都会重新计算字数太短的片段直接丢弃一般少于五个字的片段没有独立检索价值。3.3 OCR 文本与语音文本的合并OCR 的作用是补上画面里的文字信息。我用轻量级 OCR 模型逐帧识别关键帧得到界面标题、按钮名称、代码片段这些语音里不会完整念出来的内容。OCR 输出同样要清洗置信度低于阈值的框丢弃重叠框合并只保留长度大于两个字的文本。清洗完后OCR 结果会按照时间戳归属到对应的 ASR 片段上合并成一个多模态文档。举个例子语音说的是在这里配置环境变量OCR 识别出画面上有ENV_CONFIG几个字合并后的文档是在这里配置环境变量界面字段名为 ENV_CONFIG。这样的文档在检索环境变量配置在哪里时召回质量会高很多。合并时有个坑同一时间段的 OCR 结果可能和 ASR 文本语义重复比如 PPT 上的标题恰好也被念出来了。如果两条完全一致就只保留一条避免重复内容在切分后淹没检索结果。我按文本相似度做了一次过滤完全命中或 Jaccard 相似度高于 0.9 的 OCR 片段不再重复添加。3.4 去重与切分策略视频里经常出现整段重复比如培训视频开头重复讲解上节课内容或者演示过程中同一个操作被演示两遍。如果不处理检索时同一个答案会出现多个几乎一样的片段。我用 embedding 相似度做粗粒度去重把清洗后的文本片段向量化两两相似度高于 0.85 的只保留时间戳靠前的一条。对于画面去重使用感知哈希计算帧的 dHash汉明距离小于 5 的视为重复帧。这些去重逻辑放在索引之前能有效减少向量库的冗余。切分策略前面提过按 ASR segment 优先保留 metadata 里的时间戳。如果某个 segment 超过 800 字再按句号、问号、换行做降级切分并且把切出来的子片段继承同一个时间区间。这里要注意 LangChain 的 RecursiveCharacterTextSplitter 默认按字符切用在视频转写上并不合适我更习惯自己控制切分逻辑让每个片段都有清晰的起止时间。4. 索引构建与多路检索4.1 文本与图像 Embedding 选型既然是双路索引embedding 模型要分开选。文本侧我选了中文表现稳定的 bge-m3 系列支持 8192 长度的输入刚好适配长 segment而且能同时输出稠密向量和稀疏向量为后面的混合检索留了余地。图像侧用的是 CLIP 系列的 ViT-B/32把关键帧编码成图像向量。两个向量维度不同所以必须放在两个独立的向量空间里不能直接混在一个索引里比较相似度。选型时还考虑过统一模型比如把图像用多模态模型生成描述后再走文本 embedding虽然实现简单但我上面分析过信息损耗问题放弃。文本和图像各自入库后查询阶段也需要把同一个 query 分别编码query 文本走 bge-m3query 如果想搜图像可以先用 CLIP 的文本编码器编码。这一步在 LangChain 里可以封装成两个自定义 Retriever互不干扰。4.2 向量存储与混合检索向量存储我用的 FAISS因为索引规模不算大文本片段加图像帧加起来不到两万条单机内存就能扛住没必要上分布式向量数据库。但两路索引要分开创建text_index和image_index每个 Document 的 metadata 都保留video_id、start_time、end_time和时间戳字段。检索时不是只走向量相似度我加了关键词检索作为补充。文本侧用 bge-m3 的稀疏向量做 BM25 风格检索图像侧直接用文本 query 在 CLIP 空间做相似度检索。这样用户问权限设置按钮在哪里即使 embedding 匹配不到BM25 也能通过权限按钮这些词命中。这里强烈不建议只做向量检索。视频转写的口语化文本里同义改写能力再强的 embedding 也有漏召回的时候混合检索能够显著提升召回率。代价是实现复杂度增加但对视频知识库来说值得。4.3 结果融合与重排两路检索的结果要合并最有效的办法是 RRFReciprocal Rank Fusion。思路很简单每个候选片段在文本检索里有一个排名在图像检索里有一个排名融合分等于每个列表里1/(rank 60)的累加。相比直接拼接相似度分数RRF 不依赖不同向量空间的分数量纲稳定很多。我第一批融合结果里文本排名靠前但图像排名靠后的片段会被适当抬升整体更均衡。融合之后还要加一道重排。我用 bge-reranker 这种 cross-encoder 精排模型把 query 和候选片段逐对打分重排后取 top-k 作为上下文。精排模型的代价是速度慢但候选集中只有二三十条完全可控。重排会把最相关的片段顶到最前面这一步做完问答准确率提升非常明显属于又稳又见效的操作。5. 核心实现与代码骨架5.1 视频预处理模块整个流程我拆成了三个模块第一个是预处理负责抽帧和质量过滤。实际运行时先拿 FFmpeg 探测视频基本信息包括时长、分辨率、帧率再按配置的抽帧间隔和阈值执行清洗。下面这段是抽帧模块的简化版生产环境里需要在每帧完成后的清洗步骤之间断开避免大对象占内存。# preprocess.py 摘要 from pathlib import Path import cv2 class FrameCleaner: def __init__(self, blur_threshold50, diff_threshold3): self.blur_threshold blur_threshold self.diff_threshold diff_threshold def clean(self, frame): if self._is_blurry(frame): return False return True def _is_blurry(self, frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) return cv2.Laplacian(gray, cv2.CV_64F).var() self.blur_threshold这个模块最需要考虑的是内存管理四小时视频的抽帧都是逐帧流式处理不能把所有帧一次性加载到内存。而且每个关键帧除了原图外OCR 之后还要保存一份带时间戳的元数据建议统一存成 JSON Lines一行一个片段。5.2 转写与 OCR 清洗模块转写模块直接调用 Whisper 模型重点是把返回的 segments 清洗成结构化文本。OCR 模块对每个关键帧跑 OCR输出按时间戳和文本内容合并。下面这段清洗示例是核心逻辑可以看到清洗后的数据带有完整的溯源信息。# clean_and_merge.py 摘要 def clean_transcript(text, domain_terms): text text.replace(嗯, ).replace(就是说, ) text normalize_digits(text) for term, canonical in domain_terms.items(): text text.replace(term, canonical) return text.strip() def build_documents(asr_segments, ocr_results, video_id): docs [] for seg in asr_segments: text clean_transcript(seg[text], DOMAIN_TERMS) if len(text) 5: continue ocr_texts find_ocr_in_range(ocr_results, seg[start], seg[end]) merged merge_ocr_and_asr(text, ocr_texts) docs.append({ text: merged, video_id: video_id, start: seg[start], end: seg[end], }) return docs合并 OCR 那一步我踩过不少坑后面问题排查再细说。这个模块的输出格式是统一的 dict 列表后续无论是建 LangChain Document 还是存 JSON 都很方便。5.3 检索问答链路组装最后是 LangChain 组装层。清洗好的片段转成 LangChain Document两个向量库各自生成 Retriever用我写的融合检索器封装成最终 Retriever再接一个问答 Prompt。下面是问答链路的骨架# rag_chain.py 摘要 from langchain_core.documents import Document from langchain_core.prompts import ChatPromptTemplate text_docs [ Document(page_contentd[text], metadata{ video_id: d[video_id], start: d[start], end: d[end] }) for d in cleaned_segments ] image_docs [ Document(page_contentfframe_{i}, metadata{ image_path: path, video_id: d[video_id], start: d[start] }) for i, d in enumerate(frame_segments) ] # 分别构建 text_index / image_index # 自定义 fusion_retriever 内部走 RRF 融合 prompt ChatPromptTemplate.from_template( 基于以下资料回答问题并在答案后标注视频时间点。\n 资料{context}\n 问题{question}\n 答案 ) chain create_retrieval_chain( retrieverfusion_retriever, combine_docs_chain... # 按实际版本接入 LLM )实际生产环境里我把这部分的两个向量库单独落在磁盘上每次新视频入库只增量更新避免全量重建。回答链路对 LLM 的要求不高重点是把 context 里的时间戳传给生成模型让它引用出处。6. 常见问题与排查实践6.1 抽帧频率和清晰度阈值怎么定这是被问得最多的一个问题。抽帧太密索引膨胀抽帧太疏关键画面丢失。我建议先按视频类型粗调录屏视频每秒抽 0.5 帧足够因为 UI 画面变化不快外拍视频可以提高到每秒 1 帧动作演示类视频按场景切换抽。模糊阈值也不是通用的荧光灯环境的视频整体偏模糊50 的阈值会误杀很多有效帧。我的排查方法是先随机抽几百帧人工看一遍统计有效帧的 Laplacian 分布把阈值定在分布谷底附近。初始版本用的是固定经验值上线后才发现不同视频差异很大后来改成了按视频自动统计的配置方式。6.2 转写文本错乱和时间戳对不上Whisper 在处理长的纯音乐段或多人交叉说话时可能出现文本重复或时间戳跳变最典型的症状是某一段 transcript 明显和前后内容对不上或者一个 segment 跨越了整场视频。我的解决方案是转写后做一次 sanity check计算每个 segment 的时长和文本长度的比例明显异常的片段降级处理或直接标记成低置信度。低置信度片段不进向量库但保留原始文案人工抽查确认后再决定是否补索引。这个机制极大减少了脏数据进入索引的概率。另外多人说话场景务必开启 speaker diarization否则问答时会把两个人的观点混在一起。6.3 检索命中率低怎么办用户问怎么添加成员系统答不上来第一反应不应该是换 embedding 模型而是先看清洗后的数据里到底有没有添加成员这个语义。排查路径先用关键词检索在该视频的转写文本里搜成员如果文本里有但向量召回没拿到说明 embedding 匹配失败可以检查片段切分是否把关键内容切碎了如果文本里根本没有说明信息源本身缺失要么靠 OCR 补画面文字要么靠图像检索召回。我遇到最多的是后者很多操作步骤视频里没说出来只在界面按钮上显示必须依赖图像索引。所以检索命中率低时先把两路索引的结果分别打开看确认是文本路丢还是图像路丢再对症下药。6.4 成本与资源控制视频处理最耗资源的是转写和图像 embedding。选择 Whisper 的模型大小时中等模型和大型模型的中文识别效果差距没有想象中大但耗时和显存差距很大。我最终用中等模型加领域词典纠错成本降低了一半以上准确率还能接受。图像侧不要对每一帧都做 embedding清洗后只保留有效帧有效帧占比通常只有六成。向量库会持续膨胀建议每周做一次全量去重巡检把已经入库但相似度极高的片段清理掉。另外所有离线处理任务都用队列串行跑避免同时触发多个视频任务导致机器 Overload这是被线上任务卡死后学到的教训。整个项目做下来数据清洗占用了我大概六成的时间LangChain 和检索融合反而比较顺。我个人的体会是视频知识库的本质不是大模型有多强而是你喂给大模型的知识干不干净。多模态 RAG 只是放大镜数据清洗才是真正决定下限的环节。如果只能给一个建议那就是把清洗管线做成带日志的、可回溯的、分模块独立的系统宁可前期多花时间做框架也不要着急把数据灌进索引里。最后再分享一个小技巧清洗规则里所有阈值都做成配置项并且每次清洗完输出一份质量报告包含丢帧率、去重率、OCR置信度分布这些指标能帮你在后续迭代时快速发现回归问题。