多模态RAG实战:图文混合检索系统的完整工程实现
2026/4/30大约 9 分钟
多模态RAG实战:图文混合检索系统的完整工程实现
适读人群:有RAG基础、想把系统升级为图文混合检索的Java工程师 阅读时长:约18分钟
一张图把整个方案讲崩了
去年底,我们给一家制造企业做AI知识库。对方的技术负责人王总把需求说得很简单:"我们有几千份产品手册,里面很多内容都是图文混排的,用户问某个零件在哪里,你得能给我找出来。"
我当时觉得,不就是RAG嘛,加个OCR不就行了?
结果第一版上线,用户问"这个阀门的安装示意图在哪",系统给返回了一堆文字描述,没有图。用户再问"联轴器的规格表",把图里的表格识别成一坨乱码,答案完全错误。
王总把截图甩过来,就问了一句:"这是你说的AI系统?"
那一刻我意识到,纯文本RAG碰到图文混合场景,是有本质缺陷的。要做好,必须从架构层就考虑多模态。
这篇文章就是我们从那次翻车开始,一路摸索出来的完整工程实现。
多模态RAG和普通RAG有什么不同
先把问题说清楚。普通RAG的流程是:
文档 → 分块 → 文本向量化 → 存向量库 → 检索 → 丢给LLM回答多模态RAG要处理的问题复杂得多:
| 场景 | 普通RAG | 多模态RAG |
|---|---|---|
| 纯文本段落 | 直接向量化 | 同左 |
| 图片中的文字 | OCR后向量化(精度差) | 图片理解模型生成描述+向量化 |
| 表格 | 识别后乱码居多 | 结构化提取+独立索引 |
| 图文关联 | 丢失关联 | 图文绑定,联合检索 |
| 用户上传图片提问 | 不支持 | 图片向量化+文图跨模态检索 |
核心挑战有三个:
- 图片理解:不能只靠OCR,要用视觉模型真正"看懂"图片
- 跨模态检索:用户可能用文字查图片,也可能上传图片查文档
- 结果融合:图文检索结果要合理排序和整合
整体架构设计
这个架构有几个关键设计决策:
- 图片不做嵌入存储,只存描述向量;图片本体放OSS,用URL引用
- 视觉理解和文本向量化是两个独立的服务,可以分别扩展
- 检索结果融合用RRF(Reciprocal Rank Fusion)算法,而不是简单拼接
工程实现:文档解析层
先搭文档解析的核心组件。我用的是Apache PDFBox + Tika,配合Spring AI的多模态接口。
Maven依赖:
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.1</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
</dependencies>文档解析器,把PDF拆成文本块和图片块:
@Service
@Slf4j
public class MultiModalDocumentParser {
private final ChatClient chatClient;
private final OssStorageService ossService;
public MultiModalDocumentParser(ChatClient.Builder builder, OssStorageService ossService) {
this.chatClient = builder.build();
this.ossService = ossService;
}
/**
* 解析PDF文档,返回混合内容块列表
*/
public List<ContentChunk> parsePdf(byte[] pdfBytes, String docId) throws IOException {
List<ContentChunk> chunks = new ArrayList<>();
try (PDDocument document = PDDocument.load(pdfBytes)) {
PDFTextStripper textStripper = new PDFTextStripper();
for (int pageNum = 1; pageNum <= document.getNumberOfPages(); pageNum++) {
// 提取当前页文本
textStripper.setStartPage(pageNum);
textStripper.setEndPage(pageNum);
String pageText = textStripper.getText(document);
if (StringUtils.hasText(pageText.trim())) {
// 文本块:按段落分割
List<String> paragraphs = splitIntoParagraphs(pageText);
for (String para : paragraphs) {
chunks.add(ContentChunk.builder()
.type(ContentType.TEXT)
.content(para)
.docId(docId)
.pageNum(pageNum)
.build());
}
}
// 提取当前页图片
PDPage page = document.getPage(pageNum - 1);
List<byte[]> pageImages = extractImagesFromPage(document, page);
for (int imgIdx = 0; imgIdx < pageImages.size(); imgIdx++) {
byte[] imgBytes = pageImages.get(imgIdx);
String imgKey = docId + "/page" + pageNum + "_img" + imgIdx + ".png";
// 上传图片到OSS
String imgUrl = ossService.upload(imgKey, imgBytes);
// 用视觉模型生成图片描述
String description = generateImageDescription(imgBytes);
chunks.add(ContentChunk.builder()
.type(ContentType.IMAGE)
.content(description) // 存描述,用于向量化
.imageUrl(imgUrl) // 存URL,用于展示
.docId(docId)
.pageNum(pageNum)
.build());
log.info("解析图片: docId={}, page={}, imgIdx={}, descLen={}",
docId, pageNum, imgIdx, description.length());
}
}
}
return chunks;
}
/**
* 调用视觉模型理解图片内容
*/
private String generateImageDescription(byte[] imgBytes) {
String base64Img = Base64.getEncoder().encodeToString(imgBytes);
Media imageMedia = new Media(MimeTypeUtils.IMAGE_PNG,
new ByteArrayResource(imgBytes));
return chatClient.prompt()
.user(u -> u.text("""
请详细描述这张图片的内容,包括:
1. 图片类型(示意图/流程图/表格/照片等)
2. 主要内容和关键信息
3. 如果是技术图纸,描述其中的零件名称和结构关系
4. 如果有文字,完整转录出来
输出纯文本,不要Markdown格式。
""")
.media(imageMedia))
.call()
.content();
}
private List<String> splitIntoParagraphs(String text) {
return Arrays.stream(text.split("\n\n+"))
.map(String::trim)
.filter(s -> s.length() > 20)
.collect(Collectors.toList());
}
}工程实现:向量入库层
内容解析完成后,需要向量化并存入PgVector:
@Service
@Slf4j
public class MultiModalIndexService {
private final VectorStore vectorStore;
private final MultiModalDocumentParser parser;
@Transactional
public void indexDocument(byte[] fileBytes, String docId, String fileName) throws IOException {
log.info("开始索引文档: docId={}, fileName={}", docId, fileName);
List<ContentChunk> chunks = parser.parsePdf(fileBytes, docId);
log.info("文档解析完成,共{}个内容块", chunks.size());
List<Document> documents = new ArrayList<>();
for (ContentChunk chunk : chunks) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("docId", chunk.getDocId());
metadata.put("pageNum", chunk.getPageNum());
metadata.put("contentType", chunk.getType().name());
if (chunk.getType() == ContentType.IMAGE) {
metadata.put("imageUrl", chunk.getImageUrl());
}
Document doc = new Document(chunk.getContent(), metadata);
documents.add(doc);
}
// 批量向量化入库
int batchSize = 20;
for (int i = 0; i < documents.size(); i += batchSize) {
List<Document> batch = documents.subList(i,
Math.min(i + batchSize, documents.size()));
vectorStore.add(batch);
log.debug("入库进度: {}/{}", Math.min(i + batchSize, documents.size()), documents.size());
}
log.info("文档索引完成: docId={}, 共{}个向量", docId, documents.size());
}
}工程实现:混合检索层
检索层是整个系统最有技术含量的部分。我实现了文本检索+图片描述检索的融合:
@Service
@Slf4j
public class MultiModalRetrievalService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
/**
* 混合检索:同时检索文本和图片
*/
public RetrievalResult hybridSearch(String userQuery, byte[] queryImage) {
List<Document> textResults = new ArrayList<>();
List<Document> imageResults = new ArrayList<>();
// 文本查询向量检索
SearchRequest textRequest = SearchRequest.query(userQuery)
.withTopK(5)
.withSimilarityThreshold(0.6);
List<Document> allResults = vectorStore.similaritySearch(textRequest);
// 按内容类型分流
for (Document doc : allResults) {
String contentType = (String) doc.getMetadata().get("contentType");
if ("IMAGE".equals(contentType)) {
imageResults.add(doc);
} else {
textResults.add(doc);
}
}
// 如果用户上传了图片,追加图片查询
if (queryImage != null) {
String imageQueryDesc = describeQueryImage(queryImage);
SearchRequest imgRequest = SearchRequest.query(imageQueryDesc)
.withTopK(3)
.withSimilarityThreshold(0.55)
.withFilterExpression("contentType == 'IMAGE'");
imageResults.addAll(vectorStore.similaritySearch(imgRequest));
}
// RRF融合排序
List<Document> merged = rrfMerge(textResults, imageResults);
return RetrievalResult.builder()
.documents(merged)
.hasImages(!imageResults.isEmpty())
.build();
}
/**
* RRF(Reciprocal Rank Fusion)融合算法
* 把来自不同检索路径的结果合理融合
*/
private List<Document> rrfMerge(List<Document> textDocs, List<Document> imageDocs) {
Map<String, Double> scoreMap = new HashMap<>();
Map<String, Document> docMap = new HashMap<>();
int k = 60; // RRF常数
// 文本结果打分
for (int i = 0; i < textDocs.size(); i++) {
Document doc = textDocs.get(i);
String id = doc.getId();
scoreMap.merge(id, 1.0 / (k + i + 1), Double::sum);
docMap.put(id, doc);
}
// 图片结果打分(图片赋予稍高权重,因为用户经常找图)
for (int i = 0; i < imageDocs.size(); i++) {
Document doc = imageDocs.get(i);
String id = doc.getId();
scoreMap.merge(id, 1.2 / (k + i + 1), Double::sum);
docMap.put(id, doc);
}
// 按分数降序排列
return scoreMap.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(8)
.map(e -> docMap.get(e.getKey()))
.collect(Collectors.toList());
}
private String describeQueryImage(byte[] imgBytes) {
Media imageMedia = new Media(MimeTypeUtils.IMAGE_PNG,
new ByteArrayResource(imgBytes));
return chatClient.prompt()
.user(u -> u.text("用一句话描述这张图片,重点说出它的主要内容和类型")
.media(imageMedia))
.call()
.content();
}
}工程实现:问答生成层
有了检索结果,最后一步是把图文内容喂给多模态LLM生成回答:
@Service
@Slf4j
public class MultiModalQaService {
private final ChatClient chatClient;
private final MultiModalRetrievalService retrievalService;
public QaResponse answer(QaRequest request) {
// 检索相关内容
RetrievalResult retrieval = retrievalService.hybridSearch(
request.getQuestion(),
request.getQueryImage()
);
// 构建上下文
StringBuilder contextBuilder = new StringBuilder();
List<Media> imageMediaList = new ArrayList<>();
for (Document doc : retrieval.getDocuments()) {
String contentType = (String) doc.getMetadata().get("contentType");
int pageNum = (int) doc.getMetadata().get("pageNum");
if ("IMAGE".equals(contentType)) {
String imageUrl = (String) doc.getMetadata().get("imageUrl");
contextBuilder.append(String.format(
"[图片,位于第%d页,内容描述:%s]\n", pageNum, doc.getContent()));
// 如果需要把实际图片传给LLM,从OSS拉取
// imageMediaList.add(fetchImageMedia(imageUrl));
} else {
contextBuilder.append(String.format(
"[文本,位于第%d页]\n%s\n\n", pageNum, doc.getContent()));
}
}
String systemPrompt = """
你是一个专业的技术文档助手。
根据下方检索到的文档内容(包括文字和图片描述)回答用户问题。
如果答案在图片中,明确告知用户参考哪一页的图片。
如果检索内容不足以回答,直接说明,不要编造。
""";
String userMessage = String.format("""
检索到的文档内容:
---
%s
---
用户问题:%s
""", contextBuilder.toString(), request.getQuestion());
String answer = chatClient.prompt()
.system(systemPrompt)
.user(userMessage)
.call()
.content();
return QaResponse.builder()
.answer(answer)
.sourceDocuments(retrieval.getDocuments())
.hasImageReference(retrieval.isHasImages())
.build();
}
}系统数据流全览
踩坑总结
做完这个项目,踩了不少坑,总结几个最值得注意的:
| 坑点 | 问题描述 | 解决方案 |
|---|---|---|
| 图片描述质量 | GPT-4V对工程图纸描述不够精确 | 在prompt里加入领域背景,效果提升30% |
| 向量维度不匹配 | 文本和图片描述用同一模型向量化才能对比 | 统一用text-embedding-3-large |
| 大PDF解析OOM | 200MB的PDF直接OOM | 分页流式处理,不一次性加载 |
| 表格识别乱码 | PDFBox提取表格结构差 | 改用Camelot-py微服务处理表格 |
| 检索精度低 | 图片描述太泛,向量相似度低 | 改为结构化描述模板,强制输出关键词 |
小结
多模态RAG不是普通RAG加个OCR那么简单。核心是三件事:
- 用视觉模型真正理解图片,而不是OCR盲提取
- 图文绑定索引,保留关联关系
- 多路检索融合,不同模态的结果要合理排序
这套架构我们在制造企业跑了三个月,知识库里有5000+份图文混合手册,整体检索准确率比第一版提升了60%+。下一步准备把表格结构化这块做得更精细,有进展再和大家分享。
