多模态 RAG 实战——让知识库同时理解文字和图片
多模态 RAG 实战——让知识库同时理解文字和图片
适读人群:在做企业知识库、RAG 系统的开发者 | 阅读时长:约 17 分钟 | 核心价值:掌握多模态 RAG 的工程实现,搞清楚成本和效果的权衡
去年做一个制造业的设备维修知识库,客户的需求很直接:工人在现场遇到设备报错,用手机拍下报错界面和设备状态,AI 给出维修建议。
听起来不难,实际做起来踩了一堆坑。
设备手册里大量的内容是图:电路图、零件分解图、操作流程图、状态码对照表(图片格式)。如果知识库只能检索文字,这些图里的知识完全用不上,工人还是得翻手册。
这就是为什么需要多模态 RAG——不只是检索文字,还要能检索图里的内容,还要能理解用户上传的图片。
这篇文章把我在这个项目里的工程实践拆开来讲。
多模态 RAG 比普通 RAG 多了什么
普通 RAG 的流程:
文档 → 文字提取 → 切片 → 向量化 → 存入向量库
用户查询 → 向量检索 → 召回文本片段 → LLM 生成答案多模态 RAG 需要解决两个额外问题:
问题一:如何处理文档里的图片
文档里的图(流程图、表格截图、说明图)包含重要信息,普通文字提取会直接丢弃。这些图里的内容需要能被检索到。
问题二:如何处理用户上传的图片
用户可能上传图片作为查询的一部分("这个报错是什么意思"同时上传截图)。系统需要理解图片内容,然后用图片内容去检索知识库。
图片的索引方案
处理文档里的图片,有三种主流方案,各有取舍。
方案 A:图片转文字描述,只存文字
用 Vision 模型对图片生成文字描述,然后用文字做索引。
import anthropic
import base64
from pathlib import Path
def image_to_description(image_data: bytes, image_context: str = '') -> str:
"""
用 Claude Vision 将图片转换为详细的文字描述
专门针对技术文档的图片优化
"""
client = anthropic.Anthropic()
image_b64 = base64.b64encode(image_data).decode()
context_prompt = f"图片上下文:{image_context}\n\n" if image_context else ""
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1000,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}
},
{
"type": "text",
"text": f"""{context_prompt}请对这张技术文档中的图片进行详细描述。
描述要求:
1. 如果是流程图:描述流程的每个步骤和分支条件
2. 如果是电路图/接线图:描述各元件名称、连接关系、重要标注
3. 如果是表格/对照表:将所有数据转录为文字,保留行列关系
4. 如果是错误码/状态说明:完整列出所有代码及其含义
5. 如果是操作界面截图:描述每个按钮、指示灯的位置和含义
6. 其他类型:尽量描述所有可见信息,特别是数字、代码、技术参数
输出格式:
图片类型:[类型]
主要内容:[简要概括]
详细描述:[完整描述,尽量保留所有技术细节]
关键词:[提取5-10个与这张图相关的关键词,用于检索]"""
}
]
}]
)
return response.content[0].text
def process_document_images(doc_path: str) -> list[dict]:
"""
处理文档中的所有图片
返回每张图片的描述和元数据
"""
import fitz # PyMuPDF
doc = fitz.open(doc_path)
image_records = []
for page_num in range(len(doc)):
page = doc[page_num]
# 获取页面上的文字(作为图片的上下文)
page_text = page.get_text()
# 提取图片
image_list = page.get_images()
for img_idx, img_info in enumerate(image_list):
xref = img_info[0]
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
# 跳过太小的图(可能是图标)
if base_image['width'] < 100 or base_image['height'] < 100:
continue
# 生成描述
description = image_to_description(
image_data=image_bytes,
image_context=page_text[:500] # 用页面文字作为上下文
)
image_records.append({
'doc_path': doc_path,
'page': page_num + 1,
'image_index': img_idx,
'image_bytes': image_bytes,
'description': description,
'width': base_image['width'],
'height': base_image['height'],
})
doc.close()
return image_records优点:简单,和现有文字 RAG 系统完全兼容,检索用的是文字向量。 缺点:丢失了图片本身,回答时无法展示原图;描述质量依赖 Vision 模型。
方案 B:存原图 + 文字描述,检索用文字,回答时展示原图
方案 A 的升级版,把原图和描述都存起来。
import chromadb
from chromadb.utils import embedding_functions
import uuid
class MultimodalKnowledgeBase:
"""
多模态知识库
同时存储文字内容和图片(图片以描述文字索引,附带原图)
"""
def __init__(self, collection_name: str = "equipment_manual"):
self.client = chromadb.PersistentClient(path="./chroma_db")
# 文字内容集合
self.text_collection = self.client.get_or_create_collection(
name=f"{collection_name}_text",
embedding_function=embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="BAAI/bge-m3" # 中英双语 Embedding
)
)
# 图片集合(用描述文字做向量,存储图片路径)
self.image_collection = self.client.get_or_create_collection(
name=f"{collection_name}_image",
embedding_function=embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="BAAI/bge-m3"
)
)
# 图片文件存储目录
self.image_dir = Path("./knowledge_images")
self.image_dir.mkdir(exist_ok=True)
def add_text_chunk(self, text: str, metadata: dict):
"""添加文字内容"""
chunk_id = str(uuid.uuid4())
self.text_collection.add(
ids=[chunk_id],
documents=[text],
metadatas=[metadata]
)
return chunk_id
def add_image(self, image_bytes: bytes, description: str, metadata: dict):
"""
添加图片到知识库
用描述文字做向量,保存原图到磁盘
"""
image_id = str(uuid.uuid4())
# 保存原图
image_path = self.image_dir / f"{image_id}.jpg"
with open(image_path, 'wb') as f:
f.write(image_bytes)
# 用描述文字做向量
image_metadata = {
**metadata,
'image_path': str(image_path),
'type': 'image'
}
self.image_collection.add(
ids=[image_id],
documents=[description], # 描述文字用于向量检索
metadatas=[image_metadata]
)
return image_id
def search(self, query: str, n_results: int = 5) -> dict:
"""
同时检索文字和图片内容
"""
text_results = self.text_collection.query(
query_texts=[query],
n_results=n_results
)
image_results = self.image_collection.query(
query_texts=[query],
n_results=3 # 图片一般少一点
)
# 合并结果
all_results = []
for i, doc in enumerate(text_results['documents'][0]):
all_results.append({
'type': 'text',
'content': doc,
'metadata': text_results['metadatas'][0][i],
'distance': text_results['distances'][0][i]
})
for i, doc in enumerate(image_results['documents'][0]):
all_results.append({
'type': 'image',
'description': doc,
'image_path': image_results['metadatas'][0][i]['image_path'],
'metadata': image_results['metadatas'][0][i],
'distance': image_results['distances'][0][i]
})
# 按相关度排序
all_results.sort(key=lambda x: x['distance'])
return all_results[:n_results + 3] # 多返回几个,让 LLM 选择方案 C:使用多模态 Embedding(CLIP 等)
用专门的多模态 Embedding 模型,直接把图片内容编码成向量,和文字向量在同一个空间里检索。
# 使用 CLIP 模型做图文统一向量
from transformers import CLIPProcessor, CLIPModel
import torch
from PIL import Image
import io
class CLIPEmbedder:
def __init__(self):
self.model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
self.processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
def embed_text(self, text: str) -> list[float]:
inputs = self.processor(text=[text], return_tensors="pt", padding=True)
with torch.no_grad():
features = self.model.get_text_features(**inputs)
return features[0].tolist()
def embed_image(self, image_bytes: bytes) -> list[float]:
image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
inputs = self.processor(images=[image], return_tensors="pt")
with torch.no_grad():
features = self.model.get_image_features(**inputs)
return features[0].tolist()CLIP 方案的问题:CLIP 的中文支持很差,中文技术文档用 CLIP 效果不好。有中文 CLIP 的改进版(如 AltCLIP、Chinese-CLIP),但工程成熟度不如文字 Embedding 方案。
我在设备维修知识库里用的:方案 B。简单可靠,检索质量有保证,图片可以在回答里展示出来让工人对着看。
处理用户上传的查询图片
用户上传了一张设备报错界面的截图,怎么用这张图去检索知识库?
def handle_multimodal_query(
kb: MultimodalKnowledgeBase,
user_text: str = '',
user_image_bytes: bytes = None
) -> str:
"""
处理多模态查询
用户可以只发文字、只发图片、或者图片+文字一起发
"""
client = anthropic.Anthropic()
# Step 1: 如果有图片,先用 Vision 模型理解图片内容
image_understanding = ''
if user_image_bytes:
image_b64 = base64.b64encode(user_image_bytes).decode()
understand_response = client.messages.create(
model="claude-opus-4-5",
max_tokens=500,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}
},
{
"type": "text",
"text": """请描述这张图片的内容,重点提取:
1. 错误代码或错误信息(如有)
2. 设备名称或型号(如有)
3. 界面显示的状态信息
4. 其他关键技术信息
用简洁的中文描述,作为检索知识库的关键词使用。"""
}
]
}]
)
image_understanding = understand_response.content[0].text
# Step 2: 构建检索查询
if user_text and image_understanding:
search_query = f"{user_text}\n图片内容:{image_understanding}"
elif user_text:
search_query = user_text
else:
search_query = image_understanding
# Step 3: 检索知识库
results = kb.search(search_query, n_results=5)
# Step 4: 构建最终回答
return generate_answer(client, user_text, user_image_bytes, image_understanding, results)
def generate_answer(
client,
user_text: str,
user_image_bytes: bytes,
image_understanding: str,
results: list[dict]
) -> str:
"""生成最终答案"""
# 整理检索到的内容
context_parts = []
referenced_images = []
for result in results:
if result['type'] == 'text':
context_parts.append(f"参考文字:\n{result['content']}")
elif result['type'] == 'image':
context_parts.append(f"参考图片描述(第{len(referenced_images)+1}张):\n{result['description']}")
referenced_images.append(result['image_path'])
context = '\n\n---\n\n'.join(context_parts)
# 构建消息
messages_content = []
# 如果用户上传了图片,包含在请求里
if user_image_bytes:
image_b64 = base64.b64encode(user_image_bytes).decode()
messages_content.append({
"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}
})
messages_content.append({
"type": "text",
"text": f"用户上传的图片(已分析:{image_understanding})"
})
messages_content.append({
"type": "text",
"text": f"""用户问题:{user_text or '请根据图片提供帮助'}
以下是从知识库检索到的相关内容:
{context}
请根据以上信息回答用户的问题。
- 如果有明确的操作步骤,请按步骤列出
- 如果涉及到知识库里的图(参考图片描述),请说明"可参考手册第X页的图示"
- 如果信息不足以解决问题,说明需要提供更多信息"""
})
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1500,
messages=[{"role": "user", "content": messages_content}]
)
return {
'answer': response.content[0].text,
'referenced_images': referenced_images
}成本控制
多模态 RAG 最大的成本压力来自建索引时对每张图片都调用 Vision 模型。
设备维修知识库那个项目,手册总共 800 页,其中约 400 张图。用 Claude Vision 为每张图生成描述,建一次索引的成本大约 30-50 元。
这个成本完全可以接受,因为索引只需要建一次,之后查询的成本就是普通 RAG 的水平。
成本优化的几个思路:
用便宜的模型做描述:Claude Haiku 或 GPT-4o-mini 做图片描述,成本是 Claude Opus 的 1/10。对于大多数设备图来说,小模型的描述质量已经够用了。
过滤无意义的图:文档里有很多装饰性图片(公司 logo、页面背景)不需要处理,加个尺寸和内容的预过滤。
批量处理:不要一张一张调用 API,批量处理,加上本地缓存,避免重复处理。
增量更新:文档更新时只处理新增或修改的图片,不用全量重建。
实际效果和局限
设备维修知识库上线后的效果:
有效的场景:
- 错误码查询(知识库里的错误码对照表是图片,处理后能被检索到)
- 操作步骤查询(配合流程图的描述,回答里可以引导工人对照图)
- 零件名称确认(分解图描述里有零件编号,工人拍照后可以识别)
局限:
- 工人拍的现场照片质量参差不齐,光线差的时候 Vision 理解不准确
- 非常细节的电路图,图片描述会遗漏技术细节,这种场景建议直接展示原图
- 检索到的图片描述和用户问题的文字匹配度不如纯文字内容,相关图片有时候排名靠后
多模态 RAG 在"文档里有大量信息以图片形式存储"的场景下价值最大。如果你的文档基本是纯文字,不需要做多模态,普通 RAG 就够了。
