解析 ‘Multimodal RAG’:如何在 LangChain 中索引并检索图像、图表与视频片段?

各位同仁,各位对LLM与信息检索技术抱有热情的开发者们,大家好!

今天,我们齐聚一堂,共同探讨一个前沿且极具挑战性的话题:多模态检索增强生成(Multimodal RAG)。我们不仅要理解它的核心理念,更要深入实践,尤其关注如何在LangChain框架下,高效地索引并检索图像、图表乃至视频片段,从而极大地拓宽我们LLM应用的信息获取能力。

传统的RAG模型,其核心在于从文本语料库中检索相关文本片段,作为上下文输入给大型语言模型(LLM),以提升其回答的准确性、时效性和减少幻觉。然而,现实世界的信息远不止文本。图像、图表、视频承载着海量的非结构化信息,这些信息对于理解复杂概念、提供视觉证据或解释动态过程至关重要。如何让我们的LLM也能“看到”并“理解”这些非文本数据,正是多模态RAG所要解决的核心问题。

1. 多模态RAG的基石:超越文本的理解

多模态RAG的根本在于将非文本信息转化为LLM能够处理的形式,并使其可检索。这通常涉及几个关键步骤:

  1. 特征提取与表示(Representation):将图像、图表、视频等原始数据转化为某种向量表示(嵌入),或者将其内容转化为结构化或描述性的文本。
  2. 索引(Indexing):将这些表示好的信息(无论是向量还是文本)存储在高效可检索的数据库中,通常是向量数据库。
  3. 检索(Retrieval):根据用户的查询(通常是文本),从索引中找出最相关的多模态信息。
  4. 生成(Generation):将检索到的多模态信息(可能经过进一步处理)与原始查询一同输入给LLM,生成最终的答案。

LangChain作为一个强大的LLM应用开发框架,为我们提供了构建多模态RAG所需的各种组件和抽象。

2. LangChain中的多模态RAG核心组件

在LangChain中实现多模态RAG,我们将主要用到以下核心组件:

  • Document Loaders: 用于从各种源加载数据。我们需要自定义或利用现有加载器来处理图像、视频文件。
  • Document Transformers / Text Splitters: 虽然原始的文本分割器不直接适用于图像视频,但我们会用它们处理从多模态数据中提取出的文本描述。更重要的是,我们需要多模态处理器来生成这些描述或嵌入。
  • Embeddings: 将文本或多模态数据转化为向量。这是实现语义搜索的关键。
  • VectorStores: 存储和检索向量嵌入的数据库。Chroma、Pinecone、Weaviate等都是常见选择。
  • Retrievers: 从VectorStore中检索相关Document的组件。
  • Chains: 将上述组件连接起来,形成一个完整的RAG工作流。

我们将聚焦于如何将图像、图表和视频片段转化为LangChain的Document对象,并为其生成可检索的嵌入。

3. 图像与图表的索引与检索

图像和图表是信息密集型的数据。它们不仅包含视觉元素,还可能包含重要的文本信息(如标签、标题、数据点)。处理这类数据,我们有几种策略:

3.1 策略一:基于描述的检索 (Caption-based Retrieval)

这是最直接且广泛应用的方法。我们使用一个视觉-语言模型(VLM)为每张图片生成一个详细的文本描述(Caption)。然后,我们将这些描述作为文本,生成嵌入并存储在向量数据库中。

优点:实现简单,对LLM友好,因为LLM本身就是处理文本的。
缺点:描述的质量直接影响检索效果;丢失了图像本身的视觉信息,仅依赖于VLM的解释。

3.2 策略二:基于OCR的文本提取与检索 (OCR-based Retrieval)

特别是对于图表或包含大量文字的图像,光学字符识别(OCR)能够提取图像中的可见文本。这些文本可以直接作为描述的一部分,或单独进行索引。

优点:直接捕获图像中的显式文本信息,对于图表尤其有效。
缺点:无法理解图像的整体语义或非文本视觉信息;OCR准确性受图像质量影响。

3.3 策略三:多模态嵌入检索 (Multimodal Embedding Retrieval)

这是最先进的方法。我们使用一个多模态模型(如CLIP、OpenAI的text-embedding-3-smallgpt-4-vision-preview)直接为图像本身生成一个嵌入向量。查询时,用户的文本查询也会被同一个模型转化为向量,然后在向量空间中进行相似性匹配。

优点:保留了图像的视觉语义信息,能够理解图像的“内容”而不仅仅是描述。
缺点:需要更复杂的模型;查询时也需要将查询文本嵌入到相同的多模态空间。

在实际应用中,通常会结合使用这些策略,例如:为图像生成描述(策略一),同时进行OCR(策略二),并将这些文本信息结合起来,甚至与多模态嵌入(策略三)一起存储,以实现更鲁棒的检索。


3.3.1 实践:图像与图表的索引

让我们通过一个具体的例子,展示如何在LangChain中结合策略一和策略二来索引图像和图表。我们将使用一个简单的VLM(例如借助transformers库模拟)来生成描述,并使用Pytesseract进行OCR。

环境准备

首先,确保安装必要的库:

pip install langchain langchain-openai pypdf pillow transformers opencv-python pytesseract faiss-cpu
# Pytesseract需要额外的Tesseract OCR引擎安装,请参考其官方文档
# 例如在Debian/Ubuntu: sudo apt install tesseract-ocr
# 在macOS: brew install tesseract

模拟VLM和OCR功能

由于直接在代码中运行大型VLM会很慢且需要大量资源,我们将创建一个模拟函数,或者使用OpenAI的gpt-4-vision-preview或HuggingFace transformers库中的轻量级VLM。这里为了演示方便,先用一个简单的函数模拟,然后展示如何集成真正的VLM。

import os
from PIL import Image
import pytesseract
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from typing import List, Dict, Any
import base64
import io

# 设置OpenAI API Key
# os.environ["OPENAI_API_KEY"] = "YOUR_OPENAI_API_KEY"

# 1. 模拟或集成VLM进行图像描述
def describe_image_mock(image_path: str) -> str:
    """
    模拟一个图像描述函数。
    在真实场景中,这里会调用一个VLM API或本地模型,如BLIP-2, LLaVA, 或GPT-4V。
    """
    image_name = os.path.basename(image_path)
    if "chart" in image_name.lower():
        return f"这是一张关于销售数据的图表,标题可能是'年度销售概览'。图中可能有柱状图或折线图展示不同产品或年份的销售额。文件名: {image_name}"
    elif "product" in image_name.lower():
        return f"这是一张产品照片,可能展示了一个电子设备或家居用品。图中可能包含产品特写和背景环境。文件名: {image_name}"
    else:
        return f"这是一张普通的图片,内容未知。文件名: {image_name}"

def describe_image_with_gpt4v(image_path: str, client) -> str:
    """
    使用OpenAI GPT-4 Vision API描述图像。
    需要OpenAI客户端。
    """
    with open(image_path, "rb") as image_file:
        base64_image = base64.b64encode(image_file.read()).decode("utf-8")

    try:
        response = client.chat.completions.create(
            model="gpt-4-vision-preview",
            messages=[
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "详细描述这张图片的内容,包括任何可见的文本和关键视觉元素。"},
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:image/jpeg;base64,{base64_image}",
                                "detail": "high" # 或 "low"
                            },
                        },
                    ],
                }
            ],
            max_tokens=500,
        )
        return response.choices[0].message.content
    except Exception as e:
        print(f"调用GPT-4V失败: {e}")
        return describe_image_mock(image_path) # 失败时回退到mock

# 2. OCR功能
def extract_text_from_image(image_path: str) -> str:
    """
    使用Pytesseract从图像中提取文本。
    """
    try:
        image = Image.open(image_path)
        text = pytesseract.image_to_string(image, lang='chi_sim+eng') # 支持中文和英文
        return text.strip()
    except Exception as e:
        print(f"OCR失败: {e}")
        return ""

# 3. 创建LangChain Document
def create_image_document(image_path: str, client=None) -> Document:
    """
    为图像创建LangChain Document。
    结合了描述和OCR文本。
    """
    description = ""
    if client:
        description = describe_image_with_gpt4v(image_path, client)
    else:
        description = describe_image_mock(image_path)

    ocr_text = extract_text_from_image(image_path)

    # 结合描述和OCR文本作为Document内容
    page_content = f"图片描述: {description}nn图片中提取的文本:n{ocr_text}"

    # 存储原始路径和类型作为元数据
    metadata = {
        "source": image_path,
        "type": "image",
        "description": description, # 也可以将描述作为单独元数据
        "ocr_text": ocr_text # 也可以将OCR文本作为单独元数据
    }
    return Document(page_content=page_content, metadata=metadata)

# 4. 索引图像
def index_images(image_paths: List[str], embeddings: OpenAIEmbeddings, client=None) -> FAISS:
    """
    索引给定的图像路径列表。
    """
    documents = []
    for img_path in image_paths:
        print(f"处理图像: {img_path}")
        doc = create_image_document(img_path, client)
        documents.append(doc)

    print(f"共生成 {len(documents)} 个图像文档。")
    vectorstore = FAISS.from_documents(documents, embeddings)
    print("图像索引完成。")
    return vectorstore

# 示例使用
if __name__ == "__main__":
    # 模拟创建一些图像文件
    # 实际应用中,这些是真实存在的图像文件
    os.makedirs("images", exist_ok=True)
    with open("images/sales_chart.png", "w") as f: f.write("dummy chart content")
    with open("images/product_overview.jpg", "w") as f: f.write("dummy product content")
    with open("images/text_heavy_doc.png", "w") as f: f.write("dummy text content")

    image_files = [
        "images/sales_chart.png",
        "images/product_overview.jpg",
        "images/text_heavy_doc.png"
    ]

    # 初始化OpenAI客户端 (如果使用GPT-4V)
    # from openai import OpenAI
    # openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
    openai_client = None # 暂时不使用GPT-4V,用mock函数代替

    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    image_vectorstore = index_images(image_files, embeddings, client=openai_client)

    # 5. 检索图像
    print("n--- 图像检索示例 ---")
    query_image = "年度销售数据报告"
    retrieved_docs_image = image_vectorstore.similarity_search(query_image, k=2)

    print(f"n查询: '{query_image}'")
    for i, doc in enumerate(retrieved_docs_image):
        print(f"--- 检索结果 {i+1} ---")
        print(f"来源: {doc.metadata['source']}")
        print(f"类型: {doc.metadata['type']}")
        print(f"内容预览: {doc.page_content[:200]}...")
        # print(f"完整内容:n{doc.page_content}")
        print("-" * 20)

    query_product = "展示一个电子产品"
    retrieved_docs_product = image_vectorstore.similarity_search(query_product, k=1)
    print(f"n查询: '{query_product}'")
    for i, doc in enumerate(retrieved_docs_product):
        print(f"--- 检索结果 {i+1} ---")
        print(f"来源: {doc.metadata['source']}")
        print(f"类型: {doc.metadata['type']}")
        print(f"内容预览: {doc.page_content[:200]}...")
        print("-" * 20)

代码解析:

  1. describe_image_mock / describe_image_with_gpt4v: 这些函数负责生成图像的文本描述。实际项目中,你会集成更强大的VLM,例如使用transformers库加载BLIP-2模型,或者调用云服务(如GPT-4V、Google Gemini Vision)。
  2. extract_text_from_image: 利用Pytesseract库从图像中提取所有可识别的文本。这对于图表中的标题、轴标签、数据标注等尤其重要。
  3. create_image_document: 这是核心函数,它将图像文件路径作为输入,调用描述和OCR函数,然后将这些文本信息组合成page_content。同时,它将图像的元数据(如原始路径、类型、原始描述、OCR文本)存储在metadata字典中。一个Document对象代表了LangChain中可索引的最小信息单元。
  4. index_images: 遍历所有图像路径,为每个图像创建Document,然后使用OpenAIEmbeddings(或任何其他文本嵌入模型)将Documentpage_content转换为向量,并存储到FAISS向量数据库中。
  5. 检索: 通过image_vectorstore.similarity_search(query, k)方法,我们可以根据文本查询找到最相关的图像Document

3.3.2 进阶:多模态嵌入的直接应用

如果使用像CLIP或OpenAI的text-embedding-3-small(虽然它主要为文本优化,但一些多模态模型也能将其图像嵌入到同一空间)这样的模型,可以直接将图像本身转化为向量,与文本查询的向量在同一空间进行比较。LangChain目前主要通过文本嵌入来集成,因此直接的多模态图像嵌入需要更底层的集成,例如:

  1. 图像编码器: 使用CLIP模型将图像编码为向量。
  2. 文本编码器: 使用CLIP模型的文本编码器将查询文本编码为向量。
  3. 向量匹配: 在向量数据库中进行相似性搜索。

LangChain multi-vector-retriever是一个很好的模式,可以同时存储原始图片(或指向图片的URL)和其文本描述的嵌入。

# 示例:多向量检索模式的简化演示 (概念性)
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_core.messages import HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain.retrievers.multi_vector import MultiVectorRetriever
from langchain.storage import InMemoryStore
import uuid

# 假设我们有一个图像编码器和文本编码器,它们将图像和文本映射到同一向量空间
class MultimodalImageEncoder:
    def encode_image(self, image_path: str) -> List[float]:
        # 实际这里会调用CLIP等模型进行图像编码
        # 为了演示,我们返回一个基于图像内容的伪向量
        import hashlib
        with open(image_path, 'rb') as f:
            image_bytes = f.read()
        hash_val = int(hashlib.sha256(image_bytes).hexdigest(), 16)
        # 伪造一个高维向量
        return [(hash_val % 1000) / 1000.0 + i * 0.001 for i in range(1536)] # OpenAI embedding size

# 假设我们用OpenAIEmbeddings做文本和潜在的跨模态嵌入
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
image_encoder = MultimodalImageEncoder() # 实例化我们的图像编码器

# 创建一个存储原始图片(或指向图片的URL)的存储
# 这是一个简单的内存存储,实际中可以是S3、Blob Storage等
store = InMemoryStore()

# 创建一个向量数据库来存储文本描述的嵌入
vectorstore = Chroma(collection_name="image_descriptions", embedding_function=embeddings)

# MultiVectorRetriever
retriever = MultiVectorRetriever(
    vectorstore=vectorstore,
    docstore=store,
    id_key="doc_id"
)

# 准备图像数据
image_files = [
    "images/sales_chart.png",
    "images/product_overview.jpg",
    "images/text_heavy_doc.png"
]

# 索引过程
for img_path in image_files:
    doc_id = str(uuid.uuid4())

    # 获取图像描述和OCR文本
    description = describe_image_mock(img_path)
    ocr_text = extract_text_from_image(img_path)

    # 创建一个用于检索的文本Document
    # 这里的page_content是可检索的文本描述
    text_doc = Document(
        page_content=f"图片描述: {description}nn图片中提取的文本:n{ocr_text}",
        metadata={"source": img_path, "type": "image", "doc_id": doc_id}
    )

    # 存储原始图像路径(或图像二进制数据)
    # 这里的store可以存储原始图像数据,或者仅仅是图片的URL
    store.mset([(doc_id, img_path)]) # 存储原始图像路径

    # 将文本描述添加到向量数据库
    retriever.vectorstore.add_documents([text_doc])

print("n--- 多向量图像检索示例 ---")
query = "告诉我关于销售业绩的图表"
retrieved_docs = retriever.invoke(query)

print(f"n查询: '{query}'")
for i, doc in enumerate(retrieved_docs):
    print(f"--- 检索结果 {i+1} ---")
    print(f"检索到的文档内容预览: {doc.page_content[:200]}...")
    print(f"对应的原始图像路径: {doc.metadata['source']}") # 实际上,这里可以从store中获取原始图像
    print("-" * 20)

# 如果要获取原始图像,可以这样做:
# retrieved_image_paths = [store.mget([doc.metadata['doc_id']])[0] for doc in retrieved_docs]
# print(f"原始图像文件: {retrieved_image_paths}")

在这个多向量检索的例子中,我们为每个图像生成了一个唯一ID。然后:

  • 我们将图像的文本描述(包含VLM描述和OCR文本)作为可检索的Document,并将其嵌入存储在vectorstore中。
  • 我们将原始的图像路径(或者实际的图像二进制数据)与这个唯一ID关联,存储在docstore中。
  • 当执行检索时,retriever会根据查询从vectorstore中找到相关的文本描述,然后使用这些描述中的doc_iddocstore中查找对应的原始图像信息。这样,LLM就能接收到文本描述作为上下文,同时我们也能知道是哪张图片提供了这些信息。

4. 视频片段的索引与检索

视频是时间序列的图像和音频数据。处理视频比处理单张图片复杂得多。我们需要将其分解成更小的、可管理的单元,并提取多模态信息。

4.1 核心策略:视频分段与多模态信息提取

  1. 视频分段 (Segmentation):将长视频切割成有意义的短片段。这可以是固定时间间隔(如每30秒一个片段),也可以是基于内容变化(如场景切换、主题变化)的动态分段。
  2. 关键帧提取 (Keyframe Extraction):从每个视频片段中提取代表性的关键帧。这些关键帧可以被视为独立的图像进行处理(如图像描述、OCR)。
  3. 音频转录 (Audio Transcription):使用语音转文本(STT)服务将每个视频片段的音频内容转换为文本。这对于包含演讲、对话的视频至关重要。
  4. 多模态摘要 (Multimodal Summarization):对每个视频片段,结合其关键帧描述和音频转录,生成一个综合性的文本摘要。

4.2 LangChain中的实现

我们将为每个视频片段创建一个Document。这个Documentpage_content将包含该片段的转录文本、关键帧描述和片段摘要。元数据将包含视频ID、片段的开始/结束时间戳、原始视频路径等。

环境准备

pip install moviepy pydub openai-whisper opencv-python ffmpeg-python
# ffmpeg需要额外安装,请参考其官方文档
# 例如在Debian/Ubuntu: sudo apt install ffmpeg
# 在macOS: brew install ffmpeg

模拟视频处理功能

与图像类似,视频处理(关键帧提取、STT)也可能耗时。这里我们将使用一些库来模拟或简化这些步骤。

import moviepy.editor as mp
import speech_recognition as sr
import cv2
import numpy as np
import math
from pydub import AudioSegment
from pydub.silence import split_on_silence
import whisper # 用于本地STT,如果需要

# 1. 视频分段与关键帧提取
def extract_keyframes_and_segments(video_path: str, segment_duration_sec: int = 30, kf_per_segment: int = 1) -> List[Dict[str, Any]]:
    """
    将视频分段,并从每个片段中提取关键帧。
    返回一个包含片段信息和关键帧路径的列表。
    """
    video = mp.VideoFileClip(video_path)
    total_duration = video.duration
    segments_info = []

    segment_id_counter = 0
    for start_time in range(0, math.ceil(total_duration), segment_duration_sec):
        end_time = min(start_time + segment_duration_sec, total_duration)
        if start_time >= end_time:
            break

        segment_id = f"{os.path.basename(video_path).split('.')[0]}_segment_{segment_id_counter}"

        # 提取关键帧 (这里简化为从片段中间取一帧)
        frame_time = start_time + (end_time - start_time) / 2

        # 使用OpenCV从视频中精确提取帧
        cap = cv2.VideoCapture(video_path)
        cap.set(cv2.CAP_PROP_POS_MSEC, frame_time * 1000) # 定位到帧的时间 (毫秒)
        ret, frame = cap.read()
        cap.release()

        keyframe_path = None
        if ret:
            kf_dir = os.path.join("video_keyframes", segment_id)
            os.makedirs(kf_dir, exist_ok=True)
            keyframe_path = os.path.join(kf_dir, f"keyframe_{int(frame_time)}.jpg")
            cv2.imwrite(keyframe_path, frame)

        segments_info.append({
            "segment_id": segment_id,
            "video_path": video_path,
            "start_time": start_time,
            "end_time": end_time,
            "keyframe_path": keyframe_path,
            "audio_path": None # 稍后填充
        })
        segment_id_counter += 1

    video.close()
    return segments_info

# 2. 音频转录 (Speech-to-Text)
def transcribe_audio_whisper(audio_path: str) -> str:
    """
    使用OpenAI Whisper模型进行本地音频转录。
    需要先下载Whisper模型。
    """
    try:
        model = whisper.load_model("base") # 或 "small", "medium"
        result = model.transcribe(audio_path, fp16=False)
        return result["text"].strip()
    except Exception as e:
        print(f"Whisper转录失败: {e}")
        return ""

def transcribe_audio_segment(video_path: str, start_time: int, end_time: int, segment_id: str) -> str:
    """
    从视频片段中提取音频并转录。
    """
    audio_segment_path = os.path.join("video_audio_segments", f"{segment_id}.wav")
    os.makedirs(os.path.dirname(audio_segment_path), exist_ok=True)

    try:
        # 使用moviepy提取音频片段
        video = mp.VideoFileClip(video_path)
        audio_clip = video.audio.subclip(start_time, end_time)
        audio_clip.write_audiofile(audio_segment_path, logger=None)
        audio_clip.close()
        video.close()

        # 转录音频片段
        transcript = transcribe_audio_whisper(audio_segment_path)
        return transcript
    except Exception as e:
        print(f"提取或转录音频片段失败 ({segment_id}): {e}")
        return ""

# 3. 创建LangChain Document for Video Segments
def create_video_segment_document(segment_info: Dict[str, Any], client=None) -> Document:
    """
    为视频片段创建LangChain Document。
    结合了关键帧描述和音频转录。
    """
    segment_id = segment_info["segment_id"]
    video_path = segment_info["video_path"]
    start_time = segment_info["start_time"]
    end_time = segment_info["end_time"]
    keyframe_path = segment_info["keyframe_path"]

    # 描述关键帧 (同图像处理)
    keyframe_description = ""
    if keyframe_path:
        keyframe_description = describe_image_with_gpt4v(keyframe_path, client) if client else describe_image_mock(keyframe_path)

    # 转录音频
    transcript = transcribe_audio_segment(video_path, start_time, end_time, segment_id)

    # 结合所有信息
    page_content = (
        f"视频片段 ID: {segment_id}n"
        f"时间范围: {start_time:.1f}s - {end_time:.1f}sn"
        f"关键帧描述: {keyframe_description}n"
        f"音频转录: {transcript}"
    )

    metadata = {
        "source_video": video_path,
        "segment_id": segment_id,
        "type": "video_segment",
        "start_time_sec": start_time,
        "end_time_sec": end_time,
        "keyframe_path": keyframe_path,
        "keyframe_description": keyframe_description,
        "transcript": transcript
    }
    return Document(page_content=page_content, metadata=metadata)

# 4. 索引视频
def index_videos(video_paths: List[str], embeddings: OpenAIEmbeddings, client=None) -> FAISS:
    """
    索引给定的视频路径列表。
    """
    all_segment_docs = []
    for vid_path in video_paths:
        print(f"处理视频: {vid_path}")
        segments_info = extract_keyframes_and_segments(vid_path, segment_duration_sec=60) # 每60秒一个片段

        for seg_info in segments_info:
            print(f"  - 处理片段: {seg_info['segment_id']} ({seg_info['start_time']:.1f}s - {seg_info['end_time']:.1f}s)")
            doc = create_video_segment_document(seg_info, client)
            all_segment_docs.append(doc)

    print(f"共生成 {len(all_segment_docs)} 个视频片段文档。")
    if not all_segment_docs:
        print("没有生成任何视频片段文档,跳过向量存储创建。")
        return None

    vectorstore = FAISS.from_documents(all_segment_docs, embeddings)
    print("视频片段索引完成。")
    return vectorstore

# 示例使用
if __name__ == "__main__":
    # 模拟创建一个短视频文件 (需要安装ffmpeg)
    # 这个命令会创建一个10秒的空白视频
    # import subprocess
    # try:
    #     subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=black:s=640x480:d=10", "-vf", "drawtext=text='Demo Video':fontcolor=white:fontsize=24:x=(w-text_w)/2:y=(h-text_h)/2", "videos/demo_video.mp4"], check=True)
    #     print("模拟视频创建成功: videos/demo_video.mp4")
    # except Exception as e:
    #     print(f"无法创建模拟视频,请确保ffmpeg已安装并可执行。错误: {e}")
    #     print("将跳过视频索引部分。")
    #     video_files = []
    # else:
    #     video_files = ["videos/demo_video.mp4"]

    # 假设我们有真实的视频文件
    os.makedirs("videos", exist_ok=True)
    # 创建一个空的mp4文件以模拟
    with open("videos/meeting_summary.mp4", "w") as f: f.write("dummy video content")
    with open("videos/product_review.mp4", "w") as f: f.write("dummy video content")
    video_files = ["videos/meeting_summary.mp4", "videos/product_review.mp4"]

    os.makedirs("video_keyframes", exist_ok=True)
    os.makedirs("video_audio_segments", exist_ok=True)

    # 初始化OpenAI客户端 (如果使用GPT-4V)
    # from openai import OpenAI
    # openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
    openai_client = None # 暂时不使用GPT-4V,用mock函数代替

    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    video_vectorstore = index_videos(video_files, embeddings, client=openai_client)

    if video_vectorstore:
        # 5. 检索视频片段
        print("n--- 视频片段检索示例 ---")
        query_video = "会议中讨论了哪些关于产品发布的内容?"
        retrieved_docs_video = video_vectorstore.similarity_search(query_video, k=2)

        print(f"n查询: '{query_video}'")
        for i, doc in enumerate(retrieved_docs_video):
            print(f"--- 检索结果 {i+1} ---")
            print(f"来源视频: {doc.metadata['source_video']}")
            print(f"片段ID: {doc.metadata['segment_id']}")
            print(f"时间: {doc.metadata['start_time_sec']:.1f}s - {doc.metadata['end_time_sec']:.1f}s")
            print(f"内容预览: {doc.page_content[:300]}...")
            print("-" * 20)

代码解析:

  1. extract_keyframes_and_segments: 这个函数负责将视频文件分割成指定时长的片段,并从每个片段中提取一个关键帧(这里为了简化,我们取了片段的中间帧)。实际应用中,关键帧提取可以使用更复杂的算法,例如基于帧间差异或场景检测。它返回每个片段的元信息,包括开始/结束时间、关键帧路径。
  2. transcribe_audio_whisper / transcribe_audio_segment: transcribe_audio_segment负责从视频片段中提取音频,并将其保存为临时文件。然后,transcribe_audio_whisper使用OpenAI的Whisper模型(或任何其他STT服务如Google Cloud Speech-to-Text、AssemblyAI)将音频转录为文本。
  3. create_video_segment_document: 这是视频处理的核心。它结合了:
    • 通过图像描述VLM对关键帧的描述。
    • 通过STT获得的音频转录文本。
    • 这些信息被组合成page_content,作为可检索的文本。
    • 元数据包含视频来源、片段ID、时间戳、关键帧路径等,这些对于定位和播放原始视频片段非常重要。
  4. index_videos: 遍历所有视频路径,对每个视频进行分段和信息提取,为每个片段创建Document,然后使用OpenAIEmbeddings进行嵌入,并存储到FAISS向量数据库。
  5. 检索: 通过video_vectorstore.similarity_search(query, k),用户可以根据文本查询找到最相关的视频片段Document,然后利用metadata中的时间戳信息,可以直接跳转到视频的相应位置。

5. 整合检索与生成:构建多模态RAG Chain

现在我们已经可以索引和检索不同模态的数据了。下一步是将这些检索结果输入给LLM,以完成多模态RAG的最终目标:生成高质量的、包含多模态信息的答案。

LangChain的RAG Chain通常遵循以下模式:

query -> retriever -> retrieved_documents -> LLM + prompt -> answer

对于多模态RAG,retrieved_documents现在可能包含来自图像、图表和视频片段的Document。LLM需要能够理解这些混合上下文。

from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

# 假设 image_vectorstore 和 video_vectorstore 已经创建
# 我们可以创建一个统一的检索器,或者根据查询类型选择不同的检索器

# 1. 创建一个统一的检索器 (合并多个向量存储)
# 这是一个简化的合并,实际中可能需要更复杂的逻辑,例如根据查询意图选择
class CombinedRetriever:
    def __init__(self, image_store: FAISS, video_store: FAISS, text_store: FAISS = None):
        self.image_store = image_store
        self.video_store = video_store
        self.text_store = text_store # 假设还有一个文本存储

    def get_relevant_documents(self, query: str, k: int = 5) -> List[Document]:
        retrieved_docs = []
        if self.image_store:
            retrieved_docs.extend(self.image_store.similarity_search(query, k=k//2 if k > 1 else 1))
        if self.video_store:
            retrieved_docs.extend(self.video_store.similarity_search(query, k=k//2 if k > 1 else 1))
        if self.text_store:
            retrieved_docs.extend(self.text_store.similarity_search(query, k=k)) # 文本可以多一点

        # 简单去重和截断
        unique_docs = {doc.page_content: doc for doc in retrieved_docs}.values()
        return list(unique_docs)[:k]

# 假设我们也有一个文本向量存储
# text_vectorstore = FAISS.from_documents(
#     [Document(page_content="关于AI技术发展的重要报告", metadata={"source": "report.pdf", "type": "text"})],
#     embeddings
# )
text_vectorstore = None # 暂时不包含文本检索

combined_retriever = CombinedRetriever(image_vectorstore, video_vectorstore, text_vectorstore)

# 2. 定义LLM
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) # gpt-4o-mini支持多模态输入

# 3. 定义Prompt模板
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个知识渊博的助手。请根据提供的上下文信息,简洁、准确地回答用户的问题。上下文可能包含图像、图表和视频片段的描述及转录。请特别注意提及信息的来源类型(如'根据图片显示...','视频片段提到...')。"),
    ("user", "上下文信息: {context}nn问题: {input}")
])

# 4. 创建文档组合链 (stuff documents chain)
# 这个链负责将检索到的所有文档组合成一个长字符串,作为LLM的上下文。
document_chain = create_stuff_documents_chain(llm, prompt)

# 5. 创建检索链
retrieval_chain = create_retrieval_chain(combined_retriever, document_chain)

# 6. 执行RAG查询
print("n--- 多模态RAG Chain 示例 ---")
rag_query = "销售报告中提到了哪些关键趋势?视频中关于新产品发布有什么细节?"
response = retrieval_chain.invoke({"input": rag_query})

print(f"n用户问题: {rag_query}")
print(f"nLLM回答:n{response['answer']}")

# 我们可以进一步处理response['context']来展示原始的多模态内容
print("n--- 检索到的上下文信息 ---")
for i, doc in enumerate(response['context']):
    print(f"--- 上下文 {i+1} (类型: {doc.metadata.get('type', '未知')}) ---")
    if doc.metadata.get('type') == 'image':
        print(f"  来源图片: {doc.metadata['source']}")
        print(f"  描述: {doc.metadata['description'][:100]}...")
    elif doc.metadata.get('type') == 'video_segment':
        print(f"  来源视频: {doc.metadata['source_video']}")
        print(f"  时间: {doc.metadata['start_time_sec']:.1f}s - {doc.metadata['end_time_sec']:.1f}s")
        print(f"  关键帧描述: {doc.metadata['keyframe_description'][:100]}...")
        print(f"  转录: {doc.metadata['transcript'][:100]}...")
    print("-" * 20)

代码解析:

  1. CombinedRetriever: 这是一个自定义的检索器,它封装了对图像和视频向量存储的查询。在实际应用中,您可能需要更智能的逻辑来合并或排序来自不同模态的检索结果,例如:
    • 并行检索: 同时向所有模态的向量存储发送查询。
    • 混合重排: 将所有检索结果(无论模态)放在一起,使用一个跨模态的重排器(如Reranker模型)进行排序。
    • 智能路由: 根据用户查询的意图(例如,如果查询包含“图片”、“图表”,则优先检索图像;如果包含“视频”、“讲话”,则优先检索视频),动态选择检索器。
  2. ChatOpenAI(model="gpt-4o-mini"): 我们选择了一个支持多模态输入的LLM(如gpt-4o-minigpt-4-vision-preview或Google Gemini Pro Vision)。虽然我们在这里主要通过文本描述将多模态信息传递给LLM,但直接支持多模态输入的LLM在未来可能会更直接地处理原始图像或视频帧。
  3. ChatPromptTemplate: 设计一个合适的Prompt至关重要。它需要明确告知LLM上下文信息可能来自不同模态,并指导LLM如何引用这些信息(例如,“根据图表显示…”)。
  4. create_stuff_documents_chain: 这是LangChain的标准组件,它将检索器返回的所有Documentpage_content拼接成一个大字符串,作为context变量传递给LLM。
  5. create_retrieval_chain: 将检索器和文档组合链连接起来,形成一个完整的RAG工作流。

6. 高级考量与最佳实践

  • 性能与成本:多模态数据处理是计算密集型的。图像描述、视频转录、关键帧提取都需要大量CPU/GPU资源和时间。在生产环境中,考虑使用云服务(如AWS Rekognition, Google Cloud Video Intelligence, Azure AI Vision)或优化本地模型的部署。缓存结果以避免重复计算。
  • 多模态融合嵌入:虽然我们使用了文本描述进行索引,但更先进的方法是使用多模态嵌入模型,将图像、文本、甚至音频直接嵌入到同一个高维向量空间。这样,查询(无论是文本还是图像)可以直接在这个空间进行相似性搜索。
  • 上下文窗口管理:当检索到大量多模态上下文时,LLM的上下文窗口可能会成为瓶颈。可以考虑:
    • 摘要: 对每个检索到的Document进行二次摘要,再传递给LLM。
    • 重排: 使用Reranker模型对检索到的文档进行重新排序,将最相关的文档放在前面。
    • 分块策略: 对于视频,更细粒度的分段(如基于对话或场景)可以提供更精确的上下文。
  • 用户体验:当LLM回答中引用了多模态信息时,提供指向原始资源的链接(如图像URL、视频特定时间戳)至关重要,让用户可以验证或深入探索。
  • 数据管理:多模态数据量庞大,需要健壮的数据管道进行ETL(提取、转换、加载)和版本控制。
  • 错误处理与鲁棒性:图像损坏、视频编码问题、STT识别错误等都可能发生。在生产系统中需要有完善的错误处理机制。

7. 结语

多模态RAG是LLM应用发展的一个重要方向,它使得LLM能够突破文本的限制,真正感知和理解我们所处的多彩世界。通过LangChain提供的模块化组件,我们能够相对高效地构建出处理图像、图表和视频的RAG系统。这不仅提升了LLM的知识边界,也为构建更智能、更具交互性的AI应用开辟了广阔前景。挑战依然存在,但探索永无止境,期待大家将这些技术应用于更多创新场景。

感谢大家的聆听!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注