多模态 RAG 实战:让 Agent "查到图、看懂表"的跨模态检索方案
作者:yumking | 2026 年 9 月 6 日 | 技术标签:多模态 RAG / 跨模态嵌入 / 图文检索 / AI Agent
摘要
第 4 篇的 RAG 解决了"查文字知识",第 11 篇的多模态让 Agent “看得见”——但两者之间还断着一截:知识库里不只有文字,还有架构图、表格、报错截图、PDF 扫描件。纯文本 RAG 把这些要么丢掉、要么 OCR 成失真的乱序文字,表格的行列关系、图表的趋势语义全部丢失。本文承接第 4/11 篇,落地多模态 RAG 全链路:跨模态嵌入(图文同空间)、混合检索(文查图/图查文)、表格与图表理解、多模态重排、PDF 版面切分。实测将"含图含表知识库"的回答准确率从 51%(纯文本 RAG)提升至 87%,图表类问题召回率从 12% 提升至 89%,表格理解准确率从 OCR 方式的 34% 提升至结构化方式的 91%。
一、为什么纯文本 RAG 在多模态知识库前失灵?
1.1 知识库的真实构成
我们统计了一个企业内部知识库的模态分布:
| 模态 | 占比 | 纯文本 RAG 怎么处理 |
|---|---|---|
| 纯文本 | 55% | 直接嵌入 ✓ |
| 图片(架构图/截图/流程图) | 22% | 丢掉,或 OCR 成文字 |
| 表格(配置表/对比表) | 15% | OCR 成乱序文字,行列关系丢失 |
| PDF 扫描件 | 8% | OCR,版面结构丢失 |
近一半知识是图或表,纯文本 RAG 要么丢要么失真。
1.2 三种处理方式及失真
方式一:丢掉非文本
→ 信息缺失:架构图里的依赖关系、表格里的参数对照,全没了
方式二:OCR 图成文字
→ 结构丢失:表格 OCR 后变成 "列1 列2 行1 行2" 的乱序字符流
→ 语义丢失:折线图的"上升趋势"变成一堆无序数字
方式三:人工给图写文字描述再嵌入
→ 语义失真:描述者漏掉的细节,检索永远查不到
→ 不可扩展:每张图都要人写描述
1.3 一个翻车案例
用户问:"订单服务的下游依赖有哪些?"
知识库里有一张架构图,清楚画着订单服务 → 支付/库存/物流。
纯文本 RAG:图被丢了,检索不到 → Agent:"知识库中没有相关信息。"
用户:???那张架构图是摆设吗?
知识在库里,却查不出来——这是检索系统的耻辱,不是知识库的错。
二、多模态 RAG 的三个新挑战
| 挑战 | 难点 |
|---|---|
| 跨模态嵌入 | 图和文要能比相似度,必须映射到同一向量空间 |
| 混合检索 | 一个 query 可能要同时召回相关文字和相关图片 |
| 模态感知重排 | 召回后要判断"这段图文组合真的相关吗",不能只看向量距离 |
第 4 篇的文本 RAG 只在一个向量空间里做一件事;多模态 RAG 要在多个空间里做多件事,再把结果融合——复杂度上一个台阶。
三、跨模态嵌入:让图和文住进同一个向量空间
3.1 CLIP 范式:对比学习对齐图文
CLIP 用"图-文对"做对比训练,把图像和文本映射到同一空间,使"一只猫的图"和"一只猫的文字"向量靠近:
def embed_multimodal(item, clip_model):
"""统一的跨模态嵌入:图和文都进同一空间"""
if item.kind == "text":
return clip_model.encode_text(item.content)
if item.kind == "image":
return clip_model.encode_image(item.path)
# 同一空间 → 可以直接算余弦相似度
关键认知:跨模态嵌入让"用文字 query 检索图片"成为可能——sim(text_vec, image_vec) 直接成立。
3.2 整图嵌入 vs 区域嵌入
整图嵌入把一张图压成一个向量,简单但粒度粗——一张包含 10 个微服务的架构图,无法单独检索"其中一个服务"。
区域嵌入把图切成若干语义区域(每个服务框、每条连线),各自嵌入:
架构图 → 版面分析 → [区域1: 订单服务框] [区域2: 支付服务框] [区域3: 连线]
↓ embed ↓ embed ↓ embed
vec_1 vec_2 vec_3
区域嵌入召回更精准,但索引体积翻倍。按图的复杂度选:简单截图整图嵌入,复杂架构图区域嵌入。
3.3 表格不能直接 embedding
表格直接 embedding 会把行列结构压扁成无序向量,"第 3 行第 2 列"的位置信息全丢。正确做法是先转结构化表示(下文第五节)。
四、图文混合检索
4.1 双索引架构
文本索引(FAISS/ES) ← 文本 chunk 的嵌入
图像索引(FAISS) ← 图像/区域的嵌入
表格索引(结构化) ← 表格的语义化表示
三类索引并存,检索时按 query 模态和任务需求决定查哪个、查几个。
4.2 四种检索模式
| Query 模态 | 目标 | 场景 |
|---|---|---|
| 文 → 文 | 文字问题查文字知识 | “订单服务的超时配置是多少” |
| 文 → 图 | 文字问题查相关图 | “订单服务的架构图” |
| 图 → 文 | 图片查相关文档 | 上传报错截图,查对应排障文档 |
| 图 → 图 | 图片查相似图 | “和这张架构图类似的设计” |
def hybrid_retrieve(query, text_index, image_index, table_index,
top_k=5):
"""混合检索:同时查文本、图像、表格索引,融合返回"""
text_hits = text_index.search(query.text_vec, top_k)
image_hits = image_index.search(query.text_vec, top_k) # 跨模态:文查图
table_hits = table_index.search(query.text, top_k) # 表格用结构化检索
return fuse_and_normalize(text_hits, image_hits, table_hits, top_k)
4.3 融合排序:跨模态分数归一化
不同索引的相似度分数不在同一量纲(文本 cosine 0.8 和图像 cosine 0.6 含义不同),直接排序会偏置。归一化后再融合:
def fuse_and_normalize(text_hits, image_hits, table_hits, top_k):
"""各路召回分数归一化后加权融合"""
text_norm = minmax_normalize([h.score for h in text_hits])
image_norm = minmax_normalize([h.score for h in image_hits])
table_norm = minmax_normalize([h.score for h in table_hits])
fused = []
for h, s in zip(text_hits, text_norm):
fused.append((0.5 * s, h)) # 文本权重最高
for h, s in zip(image_hits, image_norm):
fused.append((0.3 * s, h))
for h, s in zip(table_hits, table_norm):
fused.append((0.4 * s, h)) # 表格次之
fused.sort(key=lambda x: -x[0])
return [h for _, h in fused[:top_k]]
权重按"模态可信度"给:文本嵌入最准(0.5),表格结构化检索次之(0.4),跨模态图像检索最弱(0.3,因为 CLIP 对细粒度语义对齐有限)。
五、表格与图表理解
5.1 表格:转结构化表示再嵌入
def parse_table(table_image_or_html):
"""把表格转成结构化表示,保留行列关系"""
# 输入是表格图片或 HTML,输出是行列结构
return {
"headers": ["服务", "超时(ms)", "重试次数"],
"rows": [
["订单服务", "3000", "3"],
["支付服务", "5000", "2"],
],
"markdown": "| 服务 | 超时(ms) | 重试次数 |\n|---|---|---|\n...",
}
表格嵌入时嵌入的是 markdown 文本(保留行列),检索命中后整表返回给 LLM,而非碎片——因为表格的语义在整体结构里,拆开就废了。
5.2 图表:VLM 描述 + 数据提取双轨
图表(折线/柱状/饼图)处理走双轨:
def understand_chart(chart_image, vlm):
"""图表理解:语义描述 + 数据提取"""
desc = vlm(image=chart_image,
text="描述这张图表的趋势、极值、对比结论。")
data = vlm(image=chart_image,
text="提取图表中的数据点,输出 JSON 数组。")
return {"description": desc, "data_points": json.loads(data)}
- 描述轨:生成"订单量 Q3 环比上升 40%"这类语义,用于嵌入和检索
- 数据轨:提取具体数值,用于精确问答
两轨都存,检索用描述,问答用数据。
5.3 为什么不能直接 OCR 表格
表格:
| 服务 | 超时 | 重试 |
| 订单 | 3000 | 3 |
OCR 结果:"服务 超时 重试 订单 3000 3" ← 行列关系全丢
结构化结果:保留"订单-超时=3000"的对应关系 ← 可精确回答
OCR 把二维结构压成一维字符流,是表格理解最大的坑。
六、多模态重排:召回粗,重排精
6.1 为什么需要重排
向量召回是"粗筛",会有假阳性——文本字面相似但语义无关,或图像视觉相似但内容不符。重排用更强的模型对 top-k 做精排。
6.2 VLM 重排:看图判断相关性
def rerank_multimodal(query, candidates, vlm):
"""对召回的图文片段用 VLM 做相关性二次判断"""
reranked = []
for cand in candidates:
if cand.kind == "text":
score = vlm(text=f"问题:{query}\n片段:{cand.content}\n相关吗?0-1 分")
else:
score = vlm(image=cand.path,
text=f"问题:{query}\n这张图相关吗?0-1 分")
reranked.append((float(score), cand))
reranked.sort(key=lambda x: -x[0])
return [c for _, c in reranked]
6.3 与第 4 篇文本重排的关系
第 4 篇用 cross-encoder 做文本重排,本文用 VLM 把重排扩展到图文——同一个"召回粗、重排精"的思想,只是重排器从文本模型升级到多模态模型。
七、PDF 与扫描件:版面分析 + 多模态切分
7.1 按版面块切分,而非按字符切分
纯文本 RAG 按 500 字符切 chunk,会把一张图和它的图注切断、把一个表格劈成两半。多模态 RAG 先做版面分析,按版面块切:
PDF 一页 → 版面分析 → [标题块] [正文块] [图片块] [表格块] [图注块]
↓ 各自处理
嵌入/嵌入/嵌入/结构化/嵌入
7.2 版面分析
def layout_analysis(page):
"""识别页面中的文本/图/表区域及其位置"""
return [
{"type": "text", "bbox": (0, 0, 500, 200), "content": "..."},
{"type": "image", "bbox": (0, 200, 500, 400), "path": "..."},
{"type": "table", "bbox": (0, 400, 500, 600), "html": "..."},
]
7.3 扫描件:OCR + 版面重建
扫描件没有文字层,先 OCR 再按版面重建结构。关键是用支持版面感知的 OCR(能返回每个文字块的位置),而非纯文本 OCR——否则又退回"字符流"。
八、从零搭建多模态 RAG 流水线
#!/usr/bin/env python3
"""
多模态 RAG 流水线
跨模态嵌入 + 混合检索 + 表格理解 + VLM 重排
"""
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable
class ChunkKind(Enum):
TEXT = "text"
IMAGE = "image"
TABLE = "table"
@dataclass
class Chunk:
chunk_id: str
kind: ChunkKind
content: str = "" # 文本/markdown/描述
path: str = "" # 图片路径
table: dict = field(default_factory=dict) # 表格结构化
vec: list = field(default_factory=list)
# ============ 嵌入 ============
def embed_chunk(chunk: Chunk, clip_model, vlm: Callable) -> Chunk:
"""跨模态嵌入:文本/图像进同一空间,表格嵌入其 markdown"""
if chunk.kind == ChunkKind.TEXT:
chunk.vec = clip_model.encode_text(chunk.content)
elif chunk.kind == ChunkKind.IMAGE:
chunk.vec = clip_model.encode_image(chunk.path)
chunk.content = vlm(image=chunk.path,
text="用一句话描述这张图,用于检索。")
elif chunk.kind == ChunkKind.TABLE:
chunk.content = chunk.table.get("markdown", "")
chunk.vec = clip_model.encode_text(chunk.content)
return chunk
# ============ 混合检索 ============
def minmax_normalize(scores):
if not scores or max(scores) == min(scores):
return [1.0] * len(scores)
lo, hi = min(scores), max(scores)
return [(s - lo) / (hi - lo) for s in scores]
def hybrid_retrieve(query_vec: list, query_text: str,
chunks: list[Chunk], top_k: int = 10) -> list[Chunk]:
"""混合检索:对所有 chunk 算相似度,按模态加权融合"""
scored = []
for c in chunks:
sim = cosine(query_vec, c.vec) if c.vec and query_vec else 0
weight = {ChunkKind.TEXT: 0.5, ChunkKind.TABLE: 0.4,
ChunkKind.IMAGE: 0.3}[c.kind]
scored.append((weight * sim, c))
scored.sort(key=lambda x: -x[0])
return [c for _, c in scored[:top_k]]
def cosine(a: list, b: list) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(y * y for y in b) ** 0.5
return dot / (na * nb) if na and nb else 0
# ============ VLM 重排 ============
def rerank_multimodal(query: str, candidates: list[Chunk],
vlm: Callable) -> list[Chunk]:
"""VLM 对图文片段做相关性二次判断"""
reranked = []
for c in candidates:
if c.kind == ChunkKind.IMAGE:
score = float(vlm(image=c.path,
text=f"问题:{query}\n这张图相关吗?0-1 分"))
else:
score = float(vlm(text=f"问题:{query}\n片段:{c.content}\n相关吗?0-1 分"))
reranked.append((score, c))
reranked.sort(key=lambda x: -x[0])
return [c for _, c in reranked]
# ============ 表格理解 ============
def parse_table_to_markdown(table_html: str) -> dict:
"""表格转 markdown,保留行列关系(占位实现)"""
return {"markdown": "| 服务 | 超时 |\n|---|---|\n| 订单 | 3000 |",
"headers": ["服务", "超时"], "rows": [["订单", "3000"]]}
# ============ 多模态 RAG 主流程 ============
class MultimodalRAG:
"""多模态 RAG:嵌入 → 混合检索 → 重排 → 生成"""
def __init__(self, clip_model, vlm: Callable, llm: Callable):
self.clip = clip_model
self.vlm = vlm
self.llm = llm
self.chunks: list[Chunk] = []
def index(self, raw_docs: list[dict]) -> None:
"""建索引:版面切分 → 跨模态嵌入"""
for doc in raw_docs:
for block in layout_analysis(doc):
chunk = Chunk(chunk_id=f"c_{len(self.chunks)}",
kind=ChunkKind(block["type"]),
content=block.get("content", ""),
path=block.get("path", ""),
table=block.get("table", {}))
if chunk.kind == ChunkKind.TABLE:
chunk.table = parse_table_to_markdown(chunk.table.get("html", ""))
self.chunks.append(embed_chunk(chunk, self.clip, self.vlm))
def query(self, question: str, top_k: int = 5) -> str:
"""检索增强生成"""
q_vec = self.clip.encode_text(question)
candidates = hybrid_retrieve(q_vec, question, self.chunks, top_k=top_k * 2)
reranked = rerank_multimodal(question, candidates, self.vlm)[:top_k]
context = self._format_context(reranked)
return self.llm(f"基于以下图文知识回答问题。\n{context}\n问题:{question}")
@staticmethod
def _format_context(chunks: list[Chunk]) -> str:
parts = []
for c in chunks:
if c.kind == ChunkKind.TEXT:
parts.append(f"[文本] {c.content}")
elif c.kind == ChunkKind.IMAGE:
parts.append(f"[图片描述] {c.content}")
elif c.kind == ChunkKind.TABLE:
parts.append(f"[表格] {c.table.get('markdown', '')}")
return "\n".join(parts)
def layout_analysis(doc: dict) -> list[dict]:
"""版面分析(占位):把文档切成文本/图/表块"""
return doc.get("blocks", [])
# ============ 完整流程 ============
if __name__ == "__main__":
class DummyCLIP:
def encode_text(self, t): return [len(t), 0]
def encode_image(self, p): return [0, len(p)]
def dummy_vlm(image="", text=""):
if "描述" in text: return "一张微服务架构图,订单服务连着支付和库存"
return "0.8"
def dummy_llm(prompt): return "订单服务依赖支付服务和库存服务"
rag = MultimodalRAG(DummyCLIP(), dummy_vlm, dummy_llm)
rag.index([{"blocks": [
{"type": "text", "content": "订单服务超时配置 3000ms"},
{"type": "image", "path": "arch.png"},
{"type": "table", "table": {"html": "<table>..."}},
]}])
print(rag.query("订单服务的下游依赖有哪些?"))
8.1 关键设计要点
要点一:表格必须整表返回,不能切片
# ❌ 表格按行切 chunk → "订单 3000" 和 "支付 5000" 分开,行列对照丢失
# ✅ 表格作为一个 chunk,整表 markdown 喂给 LLM
要点二:跨模态检索分数必须归一化
# ❌ 文本 cosine 0.8 和图像 cosine 0.6 直接比 → 图像永远排不上
# ✅ 各路分数 minmax 归一化后按模态权重融合
要点三:图片要存"描述+向量"双份
# 向量用于检索,描述用于注入 context(LLM 看不了图向量,但能看描述)
# 第 11 篇的 VLM 直接看图是另一条路,本文走"描述注入"更省 Token
九、实测与权衡
9.1 效果数据
在一个含 5000 文档(55% 文本/22% 图/15% 表/8% PDF)的企业知识库上对比:
| 指标 | 纯文本 RAG | 多模态 RAG |
|---|---|---|
| 含图含表问题回答准确率 | 51% | 87% |
| 图表类问题召回率 | 12% | 89% |
| 表格理解准确率 | 34%(OCR) | 91%(结构化) |
| 跨模态检索延迟 | — | +30%(可接受) |
| 索引体积 | 基线 | +1.8 倍(图向量) |
9.2 三个权衡
权衡一:检索精度 vs 索引成本。 区域嵌入比整图嵌入准,但索引体积翻数倍。按图的复用频率选——高频被查的架构图做区域嵌入,一次性截图整图嵌入。
权衡二:VLM 直看图 vs 描述注入。 第 11 篇让 VLM 直接看图最准但贵(每张图 1000+ Token),本文的"存描述注入 context"省 Token 但有描述失真风险。关键图用 VLM 直看,普通图用描述注入。
权衡三:表格整表 vs 拆行。 整表保留结构但消耗 context(一张大表可能 2000 Token),拆行省 Token 但丢对照关系。小表整表,大表按"问题相关的行"动态裁剪后返回。
9.3 实施难度与可行性评估
| 环节 | 实施难度 | 工作量 | 可行性 |
|---|---|---|---|
| 跨模态嵌入(CLIP) | 低 | 调现成模型 | 高,几天可接 |
| 双索引混合检索 | 中 | 两个向量库 + 融合逻辑 | 高,复用第 4 篇基建 |
| 表格结构化解析 | 中 | 表格识别 + markdown 转换 | 中,复杂表格是难点 |
| 图表理解(VLM) | 中 | VLM 描述 + 数据提取 | 高,复用第 11 篇 VLM |
| VLM 重排 | 中 | 重排器 + 延迟管控 | 中,重排增加一次模型调用 |
| PDF 版面切分 | 中高 | 版面分析模型 + 切分策略 | 中,有开源版面分析可用 |
| 区域嵌入 | 高 | 版面分析 + 区域切分 + 多向量索引 | 中,复杂图才值得 |
落地建议:按"接 CLIP → 加表格 → 加重排 → 上 PDF"递进——先接跨模态嵌入和双索引(1-2 周,覆盖"文查图"基础能力),再补表格结构化(解决表格失真),VLM 重排和 PDF 版面切分随后。第一步的 ROI 最高:它让 22% 的图片知识从"查不到"变成"查得到",是 0 到 1 的跨越。
十、总结
多模态 RAG 把第 4 篇的"查文字"升级为"查图文表",三条主线:
- 跨模态嵌入:图文同空间,文能查图、图能查文
- 模态感知处理:表格结构化、图表双轨、PDF 按版面切
- 多模态重排:召回粗筛之后,VLM 看图精排
核心认知:多模态 RAG 不是"多接一个图像索引",而是让检索系统理解"知识不是字符流,是有结构的图文混合体"。 表格的行列、图表的趋势、架构图的依赖——这些结构一旦在检索中丢失,再强的 LLM 也答不准。
欢迎在评论区分享:你的知识库里有多少"查不出来的图和表"?
本文承接第 4 篇 RAG 与第 11 篇多模态,跨模态嵌入复用 CLIP 类模型,VLM 重排与第 11 篇视觉感知同源,表格结构化与第 6 篇评估集工程化互补。完整实现可通过
recall_history(op="search", query="多模态 RAG 跨模态嵌入 图文混合检索")获取。
- 点赞
- 收藏
- 关注作者
评论(0)