第2232篇:多模态RAG——图文混排文档的智能检索架构
第2232篇:多模态RAG——图文混排文档的智能检索架构
适读人群:做知识库、企业搜索、文档处理的工程师 | 阅读时长:约18分钟 | 核心价值:解决图文混排文档的RAG难题,让技术手册、维修指南、产品说明书也能被AI准确检索
给某工程设备公司做设备维修手册查询系统那次,算是我在多模态RAG上收到的最好的一堂课。
他们的维修手册是PDF格式,但不是普通的PDF——整本手册里大量的图表:零件爆炸图、电路原理图、传感器连接图、维修操作步骤图("把这个螺丝按箭头方向拧")。文字和图片穿插排布,比例大概各占一半。
第一版系统用普通RAG:提取PDF里的文字,向量化,然后检索。技术工人问"12号阀门的拆卸步骤",系统找到了描述阀门的文字段落,但答案是残缺的——完整的拆卸步骤有三步,其中第二步是"参见图4.3",图4.3里是具体的操作示意图,文字系统根本没处理这张图。
这个问题在那个行业很有代表性。技术文档天然就是图文并茂的,纯文字RAG只解决了一半问题。
图文混排文档的特殊挑战
先搞清楚挑战是什么:
普通RAG的假设:
文档 = 纯文字内容
相似度 = 文字语义相似度
图文混排文档的现实:
文档 = 文字 + 图片 + 表格 + 公式
关键信息可能在:
- 图片里(操作步骤图、示意图、照片)
- 表格里(参数规格、对比数据)
- 文字-图片的交叉引用中("如图X所示")
- 图片的注释文字里解决这个问题有两条路:
路线A:把图片"翻译"成文字
- 用Vision模型给每张图片生成文字描述
- 把描述加入文本索引
- 检索时走普通文字检索
路线B:多模态向量化
- 用支持图文的向量模型(如CLIP)对文字和图片分别向量化
- 检索时同时查文字向量和图片向量
- 混合排序
路线A实现更简单,在大多数企业场景下效果已经很好。路线B更高级,适合以图搜图、以文字搜图片这类场景。
下面重点讲路线A的工程实现,然后介绍路线B的关键点。
路线A:图片描述注入文本索引
步骤一:PDF解析与图片提取
/**
* 多模态文档解析器
* 从PDF提取文字、图片、表格
*/
@Service
@Slf4j
public class MultimodalDocumentParser {
private final PDDocumentParser pdfParser;
private final TableExtractor tableExtractor;
/**
* 解析PDF文档,提取所有内容元素
*/
public ParsedDocument parse(byte[] pdfBytes, String documentId) {
ParsedDocument doc = new ParsedDocument(documentId);
try (PDDocument pdfDoc = PDDocument.load(pdfBytes)) {
int totalPages = pdfDoc.getNumberOfPages();
for (int pageNum = 0; pageNum < totalPages; pageNum++) {
PageContent pageContent = extractPageContent(pdfDoc, pageNum);
doc.addPage(pageContent);
}
} catch (IOException e) {
throw new DocumentParseException("PDF解析失败: " + documentId, e);
}
log.info("文档解析完成: docId={} pages={} images={} tables={}",
documentId,
doc.getPageCount(),
doc.getImageCount(),
doc.getTableCount());
return doc;
}
private PageContent extractPageContent(PDDocument doc, int pageNum) {
PageContent content = new PageContent(pageNum + 1);
// 1. 提取文字(保留位置信息)
PDFTextStripperByArea textStripper = new PDFTextStripperByArea();
textStripper.setSortByPosition(true);
// ... 提取文字逻辑
// 2. 提取图片
PDPage page = doc.getPage(pageNum);
PDResources resources = page.getResources();
for (COSName xObjectName : resources.getXObjectNames()) {
PDXObject xObject = resources.getXObject(xObjectName);
if (xObject instanceof PDImageXObject) {
PDImageXObject image = (PDImageXObject) xObject;
content.addImage(ExtractedImage.builder()
.imageId(documentId + "_p" + (pageNum + 1) + "_" + xObjectName.getName())
.pageNumber(pageNum + 1)
.imageBytes(imageToBytes(image))
.width(image.getWidth())
.height(image.getHeight())
.mimeType(image.getSuffix() != null ?
"image/" + image.getSuffix() : "image/png")
.build());
}
}
// 3. 提取表格(用Tabula或自定义逻辑)
List<ExtractedTable> tables = tableExtractor.extract(doc, pageNum);
content.addTables(tables);
return content;
}
}步骤二:Vision模型为图片生成描述
/**
* 图片语义描述生成服务
* 为每张图片生成可被文字检索的描述
*/
@Service
@Slf4j
public class ImageDescriptionService {
private final VisionModelClient visionClient;
private final ImageDescriptionRepository descriptionRepo;
/**
* 批量为文档中的图片生成描述
*
* 注意:这是批量处理,不是实时的。文档入库时异步执行。
*/
public void generateDescriptionsAsync(ParsedDocument doc) {
doc.getAllImages().forEach(image -> {
CompletableFuture.runAsync(() -> generateSingleDescription(doc, image))
.exceptionally(e -> {
log.error("图片描述生成失败: imageId={}", image.getImageId(), e);
return null;
});
});
}
private void generateSingleDescription(ParsedDocument doc, ExtractedImage image) {
// 获取图片周围的文字上下文(帮助模型更准确地理解图片)
String surroundingText = doc.getTextNearImage(image, 200); // 取图片前后200字
String prompt = String.format("""
这张图片来自一份技术文档,图片前后的文字内容是:
%s
请详细描述图片的内容,包括:
1. 图片类型(示意图/操作步骤图/参数图/照片等)
2. 图片的核心信息(如果是步骤图,描述具体步骤;如果是参数图,列出关键参数)
3. 图片中的文字标注(如有)
4. 与文档内容的关联(根据上下文推断这张图说明的是什么)
用100-200字描述,尽量具体。
""", surroundingText);
VisionResponse response = visionClient.analyze(
VisionRequest.builder()
.image(image.getImageBytes())
.prompt(prompt)
.maxTokens(300)
.build()
);
ImageDescription description = ImageDescription.builder()
.imageId(image.getImageId())
.documentId(doc.getDocumentId())
.pageNumber(image.getPageNumber())
.description(response.getContent())
.generatedAt(Instant.now())
.build();
descriptionRepo.save(description);
log.debug("图片描述已生成: imageId={}", image.getImageId());
}
}步骤三:构建混合索引
/**
* 多模态文档索引构建服务
* 文字块 + 图片描述 + 表格内容 统一建索引
*/
@Service
public class MultimodalIndexBuilder {
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
private final ImageDescriptionRepository descriptionRepo;
/**
* 为整个文档建索引
* 把文字块、图片描述、表格都作为独立的索引单元
*/
public void buildIndex(ParsedDocument doc) {
List<IndexDocument> indexDocs = new ArrayList<>();
// 1. 索引文字段落
doc.getTextChunks(CHUNK_SIZE).forEach(chunk -> {
indexDocs.add(IndexDocument.builder()
.id(chunk.getChunkId())
.content(chunk.getText())
.contentType(ContentType.TEXT)
.documentId(doc.getDocumentId())
.pageNumber(chunk.getPageNumber())
.embedding(embeddingModel.embed(chunk.getText()))
.build());
});
// 2. 索引图片描述
descriptionRepo.findByDocumentId(doc.getDocumentId()).forEach(desc -> {
// 图片描述的内容 = 描述文字 + 关联的上下文
String content = "[图片] " + desc.getDescription();
indexDocs.add(IndexDocument.builder()
.id("img_" + desc.getImageId())
.content(content)
.contentType(ContentType.IMAGE)
.documentId(doc.getDocumentId())
.pageNumber(desc.getPageNumber())
.imageId(desc.getImageId())
.embedding(embeddingModel.embed(content))
.build());
});
// 3. 索引表格(把表格转为文字描述)
doc.getAllTables().forEach(table -> {
String tableText = tableToText(table);
indexDocs.add(IndexDocument.builder()
.id("table_" + table.getTableId())
.content("[表格] " + tableText)
.contentType(ContentType.TABLE)
.documentId(doc.getDocumentId())
.pageNumber(table.getPageNumber())
.embedding(embeddingModel.embed(tableText))
.build());
});
// 批量写入向量数据库
vectorStore.upsertBatch(doc.getDocumentId() + "_index", indexDocs);
log.info("多模态索引构建完成: docId={} texts={} images={} tables={}",
doc.getDocumentId(),
indexDocs.stream().filter(d -> d.getContentType() == ContentType.TEXT).count(),
indexDocs.stream().filter(d -> d.getContentType() == ContentType.IMAGE).count(),
indexDocs.stream().filter(d -> d.getContentType() == ContentType.TABLE).count());
}
private String tableToText(ExtractedTable table) {
StringBuilder sb = new StringBuilder();
// 表头
if (table.getHeaders() != null) {
sb.append("表格列:").append(String.join(",", table.getHeaders())).append("\n");
}
// 数据行(只取前10行防止太长)
table.getRows().stream().limit(10).forEach(row -> {
sb.append(String.join(" | ", row)).append("\n");
});
return sb.toString();
}
private static final int CHUNK_SIZE = 400; // 每个文字块400字
}路线B:多模态向量化(CLIP类模型)
如果需要"以图搜文"或"以文搜图"这类功能,需要用多模态向量模型。
CLIP(Contrastive Language-Image Pre-Training)把文字和图片映射到同一个向量空间,相同语义的文字和图片在向量空间里距离很近。
/**
* 多模态向量化服务(CLIP类模型)
*/
@Service
public class MultimodalEmbeddingService {
private final CLIPModelClient clipClient;
/**
* 图片向量化
*/
public float[] embedImage(byte[] imageBytes) {
return clipClient.encodeImage(imageBytes);
}
/**
* 文字向量化(使用与图片相同的向量空间)
* 这样文字和图片可以直接比较相似度
*/
public float[] embedText(String text) {
return clipClient.encodeText(text);
}
/**
* 以文搜图:找出与查询文字最相关的图片
*/
public List<SimilarImage> searchImagesByText(
String query,
String indexName,
int topK) {
float[] queryVector = embedText(query);
return vectorStore.searchImages(indexName, queryVector, topK);
}
/**
* 以图搜图:找出与输入图片最相似的图片
*/
public List<SimilarImage> searchSimilarImages(
byte[] queryImage,
String indexName,
int topK) {
float[] imageVector = embedImage(queryImage);
return vectorStore.searchImages(indexName, imageVector, topK);
}
}检索结果的多模态展示
检索到图片相关内容后,回答不能只返回文字描述,还要把原图一并返回,让用户看到实际的图片。
/**
* 多模态RAG服务
* 检索 + 生成 + 图片关联展示
*/
@Service
public class MultimodalRAGService {
private final VectorStore vectorStore;
private final LLMClient llmClient;
private final ImageStorage imageStorage;
public MultimodalRAGResponse query(String question, String knowledgeBaseId) {
// 1. 向量检索
float[] questionVector = embeddingModel.embed(question);
List<IndexDocument> retrieved = vectorStore.search(
knowledgeBaseId, questionVector, 5);
// 2. 构建上下文(文字 + 图片描述)
StringBuilder context = new StringBuilder();
List<String> relatedImageIds = new ArrayList<>();
for (IndexDocument doc : retrieved) {
context.append(doc.getContent()).append("\n\n");
if (doc.getContentType() == ContentType.IMAGE) {
relatedImageIds.add(doc.getImageId());
}
}
// 3. LLM生成回答
String prompt = String.format("""
根据以下资料回答问题。资料中包含文字描述和图片内容描述。
【资料】
%s
【问题】
%s
如果答案涉及图片中的操作步骤,请明确说明需要参考相关图示。
""", context, question);
String answer = llmClient.call(LLMRequest.of(prompt)).getContent();
// 4. 加载相关图片(让前端展示)
List<ImageAttachment> images = relatedImageIds.stream()
.map(imageId -> ImageAttachment.builder()
.imageId(imageId)
.imageUrl(imageStorage.getUrl(imageId))
.build())
.collect(Collectors.toList());
return MultimodalRAGResponse.builder()
.answer(answer)
.relatedImages(images)
.sources(retrieved.stream().map(IndexDocument::getDocumentId)
.distinct().collect(Collectors.toList()))
.build();
}
}几个工程细节
图片过滤:不是所有图片都需要描述
大量文档里有很多装饰性图片(logo、分隔线、水印),这些不需要建索引。过滤策略:图片尺寸小于50x50像素的跳过;图片内容单一(纯色或简单线条)的跳过。
增量更新:文档更新时的索引维护
文档修订后不能全量重建索引。建立文档版本号机制,只重新处理有变化的页面。同时给图片建内容哈希缓存,相同内容的图片不重复调Vision API。
成本控制:Vision API的调用量
一个100页的技术手册,可能有200-300张图片。全部调Vision API描述一遍,成本不低。策略:
- 首次索引只处理文字,图片用占位符
- 后台异步处理图片描述,优先处理用户实际检索到的页面
- 对于小图片(可能是图标),直接跳过不处理
