RAG 系列(二十三):多模态 RAG——图片、表格也能检索

183 阅读9分钟

文本 RAG 看不见的东西

上传一份年报 PDF,里面有营收走势图、产品对比表格、架构示意图。传统 RAG 怎么处理?

  1. 用 PDF 解析器提取文本
  2. 对文本分块、Embedding、存入向量库
  3. 用户问"第三季度营收环比增长多少"

问题是:营收走势图是一张图片,PDF 解析器只会把它的 alt text(通常是空的)或者图片文件名提取出来。数字在图里,不在文本里,RAG 永远找不到。

表格情况稍好,但也有问题:解析器可能把表格拉平成一行行文字,原来的行列结构丢失,语义变得混乱。

这是真实的业务痛点。文档里 30%–50% 的信息通常以非纯文本形式存在。


三条处理路线

路线一:提取 + 文本化

最直接、最成熟的方案:把图片和表格转换成文字描述,再走标准的文本 RAG 流程。

图片处理:用视觉语言模型(VLM)生成描述

from openai import OpenAI
import base64

def describe_image(image_path: str) -> str:
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode("utf-8")
    
    client = OpenAI()
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_data}"}},
                {"type": "text", "text": "详细描述这张图片的内容,包括所有数字、标签、趋势和关键信息。如果是图表,列出所有数据点。"}
            ]
        }]
    )
    return response.choices[0].message.content

表格处理:用 pdfplumber 保留结构,转成 Markdown

import pdfplumber

def extract_tables_as_markdown(pdf_path: str) -> list[str]:
    tables_md = []
    with pdfplumber.open(pdf_path) as pdf:
        for page_num, page in enumerate(pdf.pages):
            for table in page.extract_tables():
                if not table:
                    continue
                # 第一行作表头
                header = table[0]
                rows = table[1:]
                md = "| " + " | ".join(str(h or "") for h in header) + " |\n"
                md += "| " + " | ".join("---" for _ in header) + " |\n"
                for row in rows:
                    md += "| " + " | ".join(str(c or "") for c in row) + " |\n"
                tables_md.append(f"[第{page_num+1}页表格]\n{md}")
    return tables_md

整合进 RAG 流程

from langchain_core.documents import Document

def process_document(pdf_path: str) -> list[Document]:
    docs = []
    
    # 1. 提取普通文本
    text_chunks = extract_text_chunks(pdf_path)
    docs.extend([Document(page_content=t, metadata={"type": "text", "source": pdf_path}) for t in text_chunks])
    
    # 2. 提取图片 → VLM 描述
    images = extract_images_from_pdf(pdf_path)
    for img_path, page_num in images:
        description = describe_image(img_path)
        docs.append(Document(
            page_content=description,
            metadata={"type": "image", "source": pdf_path, "page": page_num, "image_path": img_path}
        ))
    
    # 3. 提取表格 → Markdown
    tables = extract_tables_as_markdown(pdf_path)
    for table_md in tables:
        docs.append(Document(page_content=table_md, metadata={"type": "table", "source": pdf_path}))
    
    return docs

优点:兼容所有现有的文本 RAG 基础设施,不需要换向量库。
缺点:VLM 描述图片有成本和时间开销;描述质量影响检索质量;OCR 对扫描件质量敏感。


路线二:CLIP 多模态 Embedding

原理:CLIP(Contrastive Language–Image Pre-training,OpenAI 2021)把文本和图片投影到同一向量空间。"营收走势图" 这段文字的向量,和一张营收走势图的向量,在空间上会靠近。

from langchain_experimental.open_clip import OpenCLIPEmbeddings
from langchain_community.vectorstores import Chroma

# 初始化 CLIP 嵌入(使用 OpenCLIP 开源实现)
clip_embeddings = OpenCLIPEmbeddings(
    model_name="ViT-H-14",
    checkpoint="laion2b_s32b_b79k"
)

# 嵌入文本
text_embedding = clip_embeddings.embed_query("第三季度营收走势")

# 嵌入图片
image_embedding = clip_embeddings.embed_image(["path/to/chart.png"])

# 两者在同一向量空间,可以直接计算相似度
from numpy import dot
from numpy.linalg import norm
similarity = dot(text_embedding, image_embedding[0]) / (norm(text_embedding) * norm(image_embedding[0]))
print(f"相似度: {similarity:.3f}")  # 图文语义相关时通常 > 0.3

构建图文混合向量库

import uuid

# 图片和文本都存入同一个向量库
image_ids = []
for img_path in image_paths:
    img_embedding = clip_embeddings.embed_image([img_path])[0]
    doc_id = str(uuid.uuid4())
    vectorstore.add_texts(
        texts=["[IMAGE]"],  # 占位符
        embeddings=[img_embedding],
        metadatas=[{"type": "image", "path": img_path}],
        ids=[doc_id]
    )
    image_ids.append(doc_id)

# 文本用普通文本 Embedding 存入
text_vectorstore = Chroma.from_documents(text_docs, text_embeddings)

查询时的双路检索

def multimodal_search(query: str, k: int = 5):
    # 文本检索
    text_results = text_vectorstore.similarity_search(query, k=k)
    
    # 图片检索(用 CLIP 的文本 Encoder)
    query_embedding = clip_embeddings.embed_query(query)
    image_results = image_vectorstore.similarity_search_by_vector(query_embedding, k=k)
    
    # 合并结果
    return text_results + image_results

优点:图片不需要预先文字化,可以检索"视觉内容"本身。
缺点:CLIP 在自然图片上效果好,在专业图表(折线图、财务表格)上效果有限——这类图表的语义需要理解数字关系,不是纯视觉识别。


路线三:ColPali(2024 年的新思路)

背景:传统的文档 RAG 流程是:

PDF → 提取文本/图片 → 文字化 → Embedding → 检索

每一步都会丢失信息或引入误差。ColPali(Google Research,2024)换了一个思路:

PDF → 每页截图 → 视觉语言模型理解 → 页级 Embedding → 检索

直接把 PDF 页面当图像来理解,绕开文本提取。

核心技术:

  • 骨干模型:PaliGemma 3B(Google 的视觉语言模型)
  • Late Interaction(来自 ColBERT):页面被分成 1030 个 patch,每个 patch 生成独立 embedding;查询也生成 token 级 embedding;检索时做细粒度的 patch × token 相似度匹配,然后聚合打分
  • 结果:能精确定位到页面的哪个区域回答了问题
# 使用 byaldi 库(ColPali 的 Python 接口)
from byaldi import RAGMultiModalModel

# 加载 ColPali 模型
RAG = RAGMultiModalModel.from_pretrained("vidore/colpali-v1.2")

# 索引一个 PDF 目录(每页截图,生成 patch embeddings)
RAG.index(
    input_path="./financial_reports/",
    index_name="reports_index",
    store_collection_with_index=True,  # 保存原始图片供答案生成时使用
    overwrite=True,
)

# 检索(返回最相关的页面)
results = RAG.search("第三季度营收环比增长", k=3)

for r in results:
    print(f"文件: {r['doc_id']}, 页码: {r['page_num']}, 相关度: {r['score']:.3f}")

检索到页面后,用 VLM 生成答案

import base64
from openai import OpenAI

def answer_with_page_image(question: str, page_image_path: str) -> str:
    with open(page_image_path, "rb") as f:
        img_b64 = base64.b64encode(f.read()).decode("utf-8")
    
    client = OpenAI()
    return client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}},
                {"type": "text", "text": f"根据这一页的内容回答:{question}"}
            ]
        }]
    ).choices[0].message.content

ColPali 的完整流程

用户问题 → ColPali 检索最相关页面 → 取出页面图片 → 发给 VLM → 生成答案

优点

  • 天然处理图表、公式、混排内容,无需 OCR
  • 页面级理解,不破坏视觉布局
  • 在学术文档、财务报告等视觉密集型文档上效果显著优于传统方法

缺点

  • 模型较重(PaliGemma 3B),检索延迟比向量检索高
  • 依赖 NVIDIA GPU,不适合 CPU-only 部署
  • 索引时间长(每页需要前向传播)

表格的专项处理

表格和图片不同,它有结构化语义,值得专门处理。

方法一:保留 Markdown 结构

# 提取时保留表格的 Markdown 格式
def table_to_markdown(table: list[list]) -> str:
    if not table or not table[0]:
        return ""
    header = table[0]
    md = "| " + " | ".join(str(h or "-") for h in header) + " |\n"
    md += "| " + " | ".join(":---:" for _ in header) + " |\n"
    for row in table[1:]:
        md += "| " + " | ".join(str(c or "") for c in row) + " |\n"
    return md

好的 LLM 能理解 Markdown 表格并做跨行列推理。

方法二:表格摘要 + 完整表格并存

def index_table(table_md: str, table_metadata: dict) -> None:
    # 让 LLM 生成表格摘要(用于检索)
    summary = llm.invoke(
        f"用一段话描述这个表格的核心信息(不超过 100 字):\n{table_md}"
    )
    
    # 摘要用于向量检索
    vectorstore.add_texts(
        [summary.content],
        metadatas=[{**table_metadata, "full_table": table_md}]
    )

检索时用摘要找到相关表格,答案生成时把完整表格 Markdown 发给 LLM。

方法三:结构化提取 → 自然语言化

对高价值表格(财务数据、产品规格),用结构化方式提取后转成自然语言描述:

# 表格 → JSON 结构
table_json = {
    "columns": ["季度", "营收(亿)", "环比增长"],
    "rows": [
        {"季度": "Q1", "营收(亿)": 12.3, "环比增长": "+5.2%"},
        {"季度": "Q2", "营收(亿)": 14.1, "环比增长": "+14.6%"},
        {"季度": "Q3", "营收(亿)": 13.8, "环比增长": "-2.1%"},
    ]
}

# JSON → 自然语言(对检索更友好)
nl_description = (
    "2024 年各季度营收数据:Q1 营收 12.3 亿,Q2 营收 14.1 亿(环比增长 14.6%),"
    "Q3 营收 13.8 亿(环比下降 2.1%)。"
)

自然语言格式对语义检索更友好,也适合被 LLM 直接引用在答案里。


三条路线怎么选

提取 + 文本化CLIP 多模态 EmbeddingColPali
文档类型所有类型图片内容主导视觉密集型(财报、学术 PDF)
基础设施标准文本 RAG需要 CLIP 支持需要 GPU,重模型
图表理解依赖 VLM 描述质量弱(图表不是自然图片)强(页面级理解)
更新成本高(重新索引代价大)
工程复杂度
成本VLM 描述有费用模型推理费用

大多数场景的实用建议

场景                          推荐方案
─────────────────────────────────────────────────────
普通企业文档(图片少)          文本 RAG,忽略图片或 OCR
产品文档(有示意图)           提取 + GPT-4V 描述图片
财务报告/研究报告(图表密集)   ColPali
电商图片检索                  CLIP
知识库快速搭建(不想复杂化)    提取 + 文本化(最简单)

多模态 RAG 的完整 Pipeline

把以上方案组合成一个统一的 pipeline:

from enum import Enum

class DocElement(Enum):
    TEXT = "text"
    IMAGE = "image"
    TABLE = "table"

class MultimodalRAGPipeline:
    def __init__(self, text_embeddings, clip_embeddings, llm):
        self.text_emb = text_embeddings
        self.clip_emb = clip_embeddings
        self.llm = llm
        self.vectorstore = Chroma(embedding_function=text_embeddings)
    
    def index(self, pdf_path: str) -> None:
        elements = extract_all_elements(pdf_path)  # 文本/图片/表格
        docs = []
        for elem in elements:
            if elem.type == DocElement.TEXT:
                docs.append(Document(page_content=elem.content, metadata={"type": "text"}))
            elif elem.type == DocElement.IMAGE:
                caption = self._generate_caption(elem.image_path)
                docs.append(Document(
                    page_content=caption,
                    metadata={"type": "image", "path": elem.image_path}
                ))
            elif elem.type == DocElement.TABLE:
                docs.append(Document(
                    page_content=table_to_markdown(elem.content),
                    metadata={"type": "table"}
                ))
        self.vectorstore.add_documents(docs)
    
    def _generate_caption(self, image_path: str) -> str:
        return describe_image(image_path)  # 调用 GPT-4V
    
    def query(self, question: str) -> dict:
        results = self.vectorstore.similarity_search(question, k=5)
        # 构建包含图片和表格的上下文
        context_parts = []
        images_to_show = []
        for r in results:
            if r.metadata["type"] == "image":
                context_parts.append(f"[图片描述] {r.page_content}")
                images_to_show.append(r.metadata["path"])
            else:
                context_parts.append(r.page_content)
        
        answer = self.llm.invoke(
            f"基于以下内容回答问题:\n\n{'---'.join(context_parts)}\n\n问题:{question}"
        )
        return {"answer": answer.content, "images": images_to_show}

小结

多模态 RAG 的本质是把非文本信息转化为可检索的形式,然后在检索时取回原始内容供 LLM 理解。目前三条路线:

  1. 提取 + 文本化:最成熟,工程简单,但依赖 OCR/VLM 质量,适合大多数场景
  2. CLIP 多模态 Embedding:图文统一向量空间,适合自然图片检索,对专业图表效果有限
  3. ColPali:页面直接视觉处理,对图表密集型文档效果最好,但需要 GPU 和较高工程成本

表格单独处理往往比图片简单:保留 Markdown 结构 + 表格摘要,标准文本 RAG 就能很好地处理。

下一篇(也是本系列最后一篇):代码 RAG——让 AI 理解你的代码库,包括 AST 分割、代码 Embedding 模型、以及如何用知识图谱表达函数调用关系。


参考资料