多模态RAG实战:让知识库同时理解文字、图片和表格
多模态RAG实战:让知识库同时理解文字、图片和表格
那本让AI彻底"瞎"了的维修手册
小赵是一家工厂的数字化工程师,负责把工厂的设备维修知识库搬到AI上。
他花了三周把系统搭好,兴冲冲地去找维修班长老刘做验收测试。
老刘是个做了二十年的老师傅,拿过来一本《CNC加工中心液压系统维修手册》,翻到第47页,指着一张液压回路图说:
"就这个问题——液压泵异响,你让AI帮我分析一下怎么排查。"
小赵把问题输进去,AI大方地给出了一段回答:
"液压泵异响可能由以下原因导致:1. 液压油污染;2. 吸油管路空气进入;3. 液压泵内部磨损……"
老刘听完冷冷地说:"这是教科书上的通用答案,我十年前就会背了。我要的是这台型号的泵,按照图纸上这个回路,应该先检查哪个阀门,调压范围是多少,应该看哪个压力表。"
他又翻开手册:"你看,所有的诊断步骤都在这张图里,每个节点有对应的检查顺序、压力值、判断条件。但你的AI看不到这张图。"
小赵沉默了。
这本手册共180页,有76张工程图纸、38张参数表格,这些图表包含了最核心的诊断信息。但他搭的RAG系统只能处理文字,所有图片被直接跳过,所有表格被解析成乱码。
知识库里有80%的关键信息对AI来说是不存在的。
那次"验收"没通过。小赵回去重构了整个系统,加入了多模态处理能力。
两个月后,老刘再测试,AI可以根据具体的液压回路图给出精准的诊断步骤,压力参数、阀门编号全部准确。老刘翘起拇指:"这才是我要的东西。"
先说结论(TL;DR)
| 文档类型 | 处理方案 | 核心工具 |
|---|---|---|
| 纯文本 | 标准chunking + 向量化 | Spring AI |
| 截图/照片 | Vision模型转文字描述 | GPT-4o Vision |
| 工程图纸/流程图 | Vision模型提取结构信息 | GPT-4o Vision |
| PDF表格 | 结构化解析 + 自然语言描述 | Apache PDFBox + LLM |
| Excel表格 | POI解析 + LLM描述 | Apache POI |
| 图文混排文档 | 图文分离 + 分别处理 | 自定义解析器 |
适用场景:工厂设备手册、医疗器械文档、工程设计文档、包含大量图表的产品说明书。
多模态RAG的挑战
在讲方案之前,先把三个核心挑战说清楚。
挑战1:图片无法直接向量化
普通的文本embedding模型(text-embedding-3-small等)只能处理文本。图片需要先转换成文本描述,才能进入检索流程。
转换方式有两种:
- Vision模型描述法:用GPT-4o Vision等多模态模型看图生成描述
- CLIP嵌入法:用CLIP等图文对齐模型直接生成图片向量
两种方案各有优劣(后面会详细分析)。
挑战2:表格的"二维结构"在线性文本中丢失
一张5行5列的表格,包含的语义是"行列交叉位置的关系"。如果把表格直接转成文本("设备A 额定压力 15MPa 工作温度 80°C..."),上下文关系就丢失了。
解决方案:把表格转成自然语言描述,强调行列关系。
挑战3:图文关联性
文档里"如图3-2所示"这句话,指向的是同一页上的一张图。但如果图文分开处理,这个关联就断了——检索到图3-2的描述,却无法关联到提到"图3-2"的那段文字。
多模态RAG架构设计
核心思路:图文分离存储,联合检索,答案中引用原始图片。
Maven依赖与配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.laozhang.ai</groupId>
<artifactId>multimodal-rag-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI Core + OpenAI(支持Vision) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Spring AI Qdrant向量存储 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant-store-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- PDF解析 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.2</version>
</dependency>
<!-- PDF表格提取(更强大) -->
<dependency>
<groupId>technology.tabula</groupId>
<artifactId>tabula</artifactId>
<version>1.0.5</version>
</dependency>
<!-- Excel处理 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<!-- 图片处理 -->
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- Micrometer -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Commons IO -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
</project>spring:
application:
name: multimodal-rag-demo
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
chat:
options:
model: gpt-4o # 必须用支持Vision的模型
temperature: 0.1
max-tokens: 4096
embedding:
options:
model: text-embedding-3-small
vectorstore:
qdrant:
host: localhost
port: 6334
collection-name: multimodal-docs
initialize-schema: true
datasource:
url: jdbc:postgresql://localhost:5432/multimodal_rag
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: update
# 多模态处理配置
multimodal:
# 图片处理
image:
# 最大处理尺寸(超过则缩放,降低Vision API成本)
max-width: 1024
max-height: 1024
# 是否处理所有图片(false=只处理包含文字/图表的图片)
process-all: false
# 最小尺寸(太小的图片跳过处理)
min-width: 100
min-height: 100
# 图片存储路径(本地开发用)
storage-path: /tmp/multimodal-rag/images
# 表格处理
table:
# 最大单次处理行数(超过则分批)
max-rows-per-chunk: 50
# Vision API并发控制(避免速率限制)
vision:
concurrency: 3
retry-times: 2
retry-delay-ms: 2000
# 成本控制
cost:
# 跳过Vision处理的最小图片字节数(太小的图片通常是图标,不值得处理)
min-bytes-for-vision: 5000
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
logging:
level:
com.laozhang.ai: DEBUGPDF文档解析器
package com.laozhang.ai.multimodal.parser;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
/**
* PDF文档解析器
* 从PDF中提取文本、图片和表格(图片形式的表格也能处理)
*/
@Component
@Slf4j
public class PdfDocumentParser {
@Value("${multimodal.image.storage-path:/tmp/multimodal-rag/images}")
private String imageStoragePath;
@Value("${multimodal.image.max-width:1024}")
private int maxWidth;
@Value("${multimodal.image.max-height:1024}")
private int maxHeight;
@Value("${multimodal.image.min-width:100}")
private int minWidth;
@Value("${multimodal.image.min-height:100}")
private int minHeight;
@Value("${multimodal.cost.min-bytes-for-vision:5000}")
private long minBytesForVision;
/**
* 解析PDF文档,提取文本块和图片块
*
* @param pdfFile PDF文件
* @return 解析结果
*/
public ParseResult parse(File pdfFile) throws IOException {
log.info("开始解析PDF:{}", pdfFile.getName());
long start = System.currentTimeMillis();
// 确保图片存储目录存在
Files.createDirectories(Paths.get(imageStoragePath));
List<TextChunk> textChunks = new ArrayList<>();
List<ImageChunk> imageChunks = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(pdfFile)) {
int totalPages = document.getNumberOfPages();
log.info("PDF共{}页", totalPages);
// 1. 提取全文(按页)
PDFTextStripper stripper = new PDFTextStripper();
for (int page = 1; page <= totalPages; page++) {
stripper.setStartPage(page);
stripper.setEndPage(page);
String pageText = stripper.getText(document).trim();
if (!pageText.isEmpty()) {
textChunks.add(TextChunk.builder()
.id(UUID.randomUUID().toString())
.content(pageText)
.pageNumber(page)
.sourceFile(pdfFile.getName())
.build());
}
}
// 2. 提取图片(按页遍历所有图片资源)
for (int pageIdx = 0; pageIdx < totalPages; pageIdx++) {
PDPage page = document.getPage(pageIdx);
int pageNum = pageIdx + 1;
// 遍历页面资源中的图片
page.getResources().getXObjectNames().forEach(name -> {
try {
var xObject = page.getResources().getXObject(name);
if (xObject instanceof PDImageXObject imageXObject) {
BufferedImage image = imageXObject.getImage();
// 过滤太小的图片(通常是图标/装饰)
if (image.getWidth() < minWidth || image.getHeight() < minHeight) {
log.debug("跳过小图片,尺寸={}x{}", image.getWidth(), image.getHeight());
return;
}
// 检查图片大小(字节数)
byte[] imageBytes = toJpegBytes(image);
if (imageBytes.length < minBytesForVision) {
log.debug("跳过小文件图片,字节数={}", imageBytes.length);
return;
}
// 保存图片到本地(同时保留缩放版用于Vision API)
String imageId = UUID.randomUUID().toString();
String imagePath = saveImage(image, imageId);
String base64 = Base64.getEncoder().encodeToString(
toJpegBytes(resizeIfNeeded(image))
);
imageChunks.add(ImageChunk.builder()
.id(imageId)
.pageNumber(pageNum)
.localPath(imagePath)
.base64Data(base64)
.width(image.getWidth())
.height(image.getHeight())
.sourceFile(pdfFile.getName())
.build());
}
} catch (Exception e) {
log.warn("提取第{}页图片失败", pageNum, e);
}
});
}
}
long elapsed = System.currentTimeMillis() - start;
log.info("PDF解析完成:文本块{}个,图片{}个,耗时{}ms",
textChunks.size(), imageChunks.size(), elapsed);
return ParseResult.builder()
.textChunks(textChunks)
.imageChunks(imageChunks)
.sourceFile(pdfFile.getName())
.totalPages(0)
.build();
}
/**
* 将BufferedImage保存到本地
*/
private String saveImage(BufferedImage image, String imageId) throws IOException {
String filename = imageId + ".jpg";
Path path = Paths.get(imageStoragePath, filename);
ImageIO.write(image, "JPEG", path.toFile());
return path.toString();
}
/**
* 转换为JPEG字节数组
*/
private byte[] toJpegBytes(BufferedImage image) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "JPEG", baos);
return baos.toByteArray();
}
/**
* 如有必要,缩放图片(降低Vision API的token消耗)
*/
private BufferedImage resizeIfNeeded(BufferedImage original) throws IOException {
if (original.getWidth() <= maxWidth && original.getHeight() <= maxHeight) {
return original;
}
// 保持宽高比缩放
double ratio = Math.min(
(double) maxWidth / original.getWidth(),
(double) maxHeight / original.getHeight()
);
int newWidth = (int) (original.getWidth() * ratio);
int newHeight = (int) (original.getHeight() * ratio);
return net.coobird.thumbnailator.Thumbnails.of(original)
.size(newWidth, newHeight)
.asBufferedImage();
}
// ==================== 数据模型 ====================
@lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class ParseResult {
private String sourceFile;
private int totalPages;
private List<TextChunk> textChunks;
private List<ImageChunk> imageChunks;
}
@lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class TextChunk {
private String id;
private String content;
private int pageNumber;
private String sourceFile;
}
@lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class ImageChunk {
private String id;
private int pageNumber;
private String localPath;
private String base64Data;
private int width;
private int height;
private String sourceFile;
private String description; // Vision API生成的描述
}
}Vision模型:图片转文字描述
package com.laozhang.ai.multimodal.service;
import com.laozhang.ai.multimodal.parser.PdfDocumentParser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.Media;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeTypeUtils;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.*;
/**
* Vision模型服务
* 用GPT-4o Vision将图片转换为结构化文字描述
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class VisionService {
private final ChatModel chatModel;
@Value("${multimodal.vision.concurrency:3}")
private int concurrency;
@Value("${multimodal.vision.retry-times:2}")
private int retryTimes;
@Value("${multimodal.vision.retry-delay-ms:2000}")
private long retryDelayMs;
/**
* 工程图纸/技术图表的描述提示词
* 不同类型的图片用不同的提示词效果更好
*/
private static final String TECHNICAL_DIAGRAM_PROMPT = """
请详细描述这张技术图纸或工程图,提取以下信息:
1. **图纸类型**:液压图/电气图/机械图/流程图/其他
2. **主要组件**:列出图中所有标注的零件、设备、节点
3. **连接关系**:组件之间如何连接,信号/流体/数据流向
4. **数值参数**:图中标注的所有数字参数(压力、温度、尺寸、型号等)
5. **操作步骤**:如果图中有顺序编号(①②③),按顺序描述操作步骤
6. **注意事项**:图中的警告、注意、危险标识
7. **图例说明**:如果有图例,解释各符号含义
请用中文描述,力求精确,不要遗漏任何标注文字和数字。
格式要清晰,便于后续检索使用。
""";
private static final String DATA_TABLE_PROMPT = """
这是一张数据表格图片,请提取表格中的所有数据:
1. **表格标题**(如有)
2. **列名**:列出所有列的名称
3. **数据内容**:以结构化方式描述每行数据,重点是数值和关键参数
4. **单位**:每个参数的单位
5. **备注**:表格中的任何注释或说明
输出格式:先列出表格结构,再按行描述数据。
请用中文,确保所有数值准确无误。
""";
private static final String GENERAL_IMAGE_PROMPT = """
请描述这张图片的内容,重点关注:
1. 图片类型(照片/截图/图表/示意图)
2. 主要内容和关键信息
3. 图中的所有文字、数字
4. 如果是设备照片,描述设备的状态、可见的标签和参数
请用中文描述,力求准确全面。
""";
/**
* 批量处理图片,生成描述
*
* @param images 图片列表
* @param docTitle 文档标题(帮助Vision模型理解上下文)
* @return 处理完成的图片列表(含描述)
*/
public List<PdfDocumentParser.ImageChunk> batchDescribeImages(
List<PdfDocumentParser.ImageChunk> images,
String docTitle) {
log.info("开始批量图片描述生成,共{}张,文档={}",
images.size(), docTitle);
// 使用有界线程池控制并发(避免Vision API速率限制)
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
List<CompletableFuture<Void>> futures = images.stream()
.map(image -> CompletableFuture.runAsync(
() -> processImageWithRetry(image, docTitle),
executor
))
.toList();
// 等待所有处理完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
executor.shutdown();
long successCount = images.stream()
.filter(img -> img.getDescription() != null && !img.getDescription().isEmpty())
.count();
log.info("图片描述生成完成:成功{}/{}张", successCount, images.size());
return images;
}
/**
* 处理单张图片(带重试)
*/
private void processImageWithRetry(
PdfDocumentParser.ImageChunk image,
String docTitle) {
for (int attempt = 0; attempt <= retryTimes; attempt++) {
try {
String description = describeImage(image, docTitle);
image.setDescription(description);
log.debug("图片描述生成成功,imageId={}", image.getId());
return;
} catch (Exception e) {
if (attempt < retryTimes) {
log.warn("图片描述失败(第{}次),imageId={},等待{}ms后重试",
attempt + 1, image.getId(), retryDelayMs);
try {
Thread.sleep(retryDelayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
} else {
log.error("图片描述最终失败,imageId={}", image.getId(), e);
// 失败时生成一个基础描述,不让这张图完全丢失
image.setDescription(String.format(
"图片(第%d页,尺寸%dx%d,来自文档:%s)",
image.getPageNumber(), image.getWidth(),
image.getHeight(), docTitle
));
}
}
}
}
/**
* 调用Vision API描述单张图片
*/
private String describeImage(
PdfDocumentParser.ImageChunk image,
String docTitle) {
// 根据图片尺寸和上下文选择提示词
String prompt = selectPrompt(image, docTitle);
// 构建包含图片的消息
// Spring AI 1.0支持直接传Base64图片
byte[] imageBytes = Base64.getDecoder().decode(image.getBase64Data());
UserMessage userMessage = new UserMessage(
prompt,
List.of(new Media(
MimeTypeUtils.IMAGE_JPEG,
new ByteArrayResource(imageBytes)
))
);
// 添加文档上下文
String systemContext = String.format(
"你正在处理来自《%s》第%d页的图片,请结合文档类型进行专业描述。",
docTitle, image.getPageNumber()
);
String response = ChatClient.create(chatModel)
.prompt()
.system(systemContext)
.messages(List.of(userMessage))
.call()
.content();
return response;
}
/**
* 根据图片特征选择合适的提示词
*/
private String selectPrompt(PdfDocumentParser.ImageChunk image, String docTitle) {
// 根据文档类型和图片比例判断
double aspectRatio = (double) image.getWidth() / image.getHeight();
// 宽图(宽高比>2)通常是表格或时序图
if (aspectRatio > 2.0) {
return DATA_TABLE_PROMPT;
}
// 正方形附近的图通常是技术图纸
if (aspectRatio > 0.7 && aspectRatio < 1.5) {
return TECHNICAL_DIAGRAM_PROMPT;
}
return GENERAL_IMAGE_PROMPT;
}
}表格提取:PDF表格转结构化描述
package com.laozhang.ai.multimodal.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import technology.tabula.*;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* PDF表格提取服务
* 使用Tabula提取PDF中的真实表格(非图片表格),
* 然后用LLM生成自然语言描述
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PdfTableExtractorService {
private final ChatClient chatClient;
/**
* 表格转自然语言描述的提示词
*/
private static final String TABLE_DESCRIPTION_PROMPT = """
请将以下表格数据转换成自然语言描述,要求:
1. 先说明表格的整体含义(这是什么参数的表格)
2. 描述表格结构(几行几列,列名是什么)
3. 总结数据规律(如:压力随温度增加而升高,最大值是X,最小值是Y)
4. 特别标注异常值或关键参数
5. 保留所有具体数值,确保数值准确
表格数据(CSV格式):
{table_csv}
文档上下文:{context}
请输出清晰的中文描述,便于后续问答检索使用。
""";
/**
* 从PDF中提取所有表格并生成描述
*
* @param pdfFile PDF文件
* @param docTitle 文档标题
* @return 表格描述列表
*/
public List<TableChunk> extractAndDescribeTables(
File pdfFile, String docTitle) throws IOException {
log.info("开始提取PDF表格,文件={}", pdfFile.getName());
List<TableChunk> tableChunks = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(pdfFile)) {
ObjectExtractor extractor = new ObjectExtractor(document);
SpreadsheetExtractionAlgorithm algorithm = new SpreadsheetExtractionAlgorithm();
for (int pageNum = 1; pageNum <= document.getNumberOfPages(); pageNum++) {
Page page = extractor.extract(pageNum);
List<Table> tables = algorithm.extract(page);
for (int tableIdx = 0; tableIdx < tables.size(); tableIdx++) {
Table table = tables.get(tableIdx);
if (table.getRows().isEmpty()) continue;
// 转换为CSV格式
String csvData = tableToCSV(table);
// 用LLM生成自然语言描述
String description = generateTableDescription(
csvData,
docTitle,
pageNum,
tableIdx + 1
);
tableChunks.add(TableChunk.builder()
.id(java.util.UUID.randomUUID().toString())
.pageNumber(pageNum)
.tableIndex(tableIdx + 1)
.csvData(csvData)
.description(description)
.rowCount(table.getRows().size())
.columnCount(table.getRows().isEmpty() ? 0 :
table.getRows().get(0).size())
.sourceFile(pdfFile.getName())
.build());
}
}
}
log.info("表格提取完成:共{}个表格", tableChunks.size());
return tableChunks;
}
/**
* 将Tabula表格转为CSV字符串
*/
private String tableToCSV(Table table) {
StringBuilder sb = new StringBuilder();
for (List<RectangularTextContainer> row : table.getRows()) {
List<String> cells = row.stream()
.map(cell -> {
String text = cell.getText().trim()
.replace(",", ",") // 避免破坏CSV格式
.replace("\n", " ");
return text;
})
.toList();
sb.append(String.join(",", cells)).append("\n");
}
return sb.toString();
}
/**
* 用LLM生成表格的自然语言描述
*/
private String generateTableDescription(
String csvData,
String docTitle,
int pageNum,
int tableIdx) {
// 如果表格太大,只取前50行
String truncatedCsv = truncateCsv(csvData, 50);
String prompt = TABLE_DESCRIPTION_PROMPT
.replace("{table_csv}", truncatedCsv)
.replace("{context}", String.format(
"来自《%s》第%d页第%d个表格", docTitle, pageNum, tableIdx
));
try {
return chatClient.prompt()
.user(prompt)
.call()
.content();
} catch (Exception e) {
log.error("表格描述生成失败,page={}, tableIdx={}", pageNum, tableIdx, e);
return String.format("表格(%s 第%d页第%d个,%s)",
docTitle, pageNum, tableIdx,
truncatedCsv.substring(0, Math.min(200, truncatedCsv.length())));
}
}
/**
* 截断过长的CSV
*/
private String truncateCsv(String csv, int maxRows) {
String[] lines = csv.split("\n");
if (lines.length <= maxRows) return csv;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < maxRows; i++) {
sb.append(lines[i]).append("\n");
}
sb.append(String.format("... (共%d行,只显示前%d行)", lines.length, maxRows));
return sb.toString();
}
@lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class TableChunk {
private String id;
private int pageNumber;
private int tableIndex;
private String csvData;
private String description;
private int rowCount;
private int columnCount;
private String sourceFile;
}
}知识图谱索引构建
package com.laozhang.ai.multimodal.service;
import com.laozhang.ai.multimodal.parser.PdfDocumentParser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* 多模态索引构建服务
* 将文本、图片描述、表格描述统一索引到向量数据库
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MultimodalIndexService {
private final VectorStore vectorStore;
private final VisionService visionService;
private final PdfTableExtractorService tableExtractorService;
/**
* 完整处理并索引一个文档
*
* @param parseResult PDF解析结果
* @param docTitle 文档标题
*/
public IndexResult indexDocument(
PdfDocumentParser.ParseResult parseResult,
String docTitle) {
log.info("开始多模态索引构建,文档={}", docTitle);
long start = System.currentTimeMillis();
List<Document> allDocuments = new ArrayList<>();
int textCount = 0, imageCount = 0, tableCount = 0;
// 1. 索引文本块
for (PdfDocumentParser.TextChunk chunk : parseResult.getTextChunks()) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("type", "text");
metadata.put("source_file", chunk.getSourceFile());
metadata.put("page_number", chunk.getPageNumber());
metadata.put("doc_title", docTitle);
allDocuments.add(new Document(
chunk.getId(),
chunk.getContent(),
metadata
));
textCount++;
}
// 2. 处理图片 → 生成描述 → 索引
if (!parseResult.getImageChunks().isEmpty()) {
List<PdfDocumentParser.ImageChunk> processedImages =
visionService.batchDescribeImages(
parseResult.getImageChunks(), docTitle
);
for (PdfDocumentParser.ImageChunk image : processedImages) {
if (image.getDescription() == null || image.getDescription().isEmpty()) {
continue;
}
Map<String, Object> metadata = new HashMap<>();
metadata.put("type", "image");
metadata.put("source_file", image.getSourceFile());
metadata.put("page_number", image.getPageNumber());
metadata.put("doc_title", docTitle);
metadata.put("image_local_path", image.getLocalPath());
// 存储图片访问URL(实际部署时替换为OSS URL)
metadata.put("image_url", "/api/images/" + image.getId());
metadata.put("image_width", image.getWidth());
metadata.put("image_height", image.getHeight());
// 索引内容 = 图片描述(不是原始图片)
String indexContent = String.format(
"[图片] %s\n页面:%d\n描述:%s",
image.getSourceFile(), image.getPageNumber(),
image.getDescription()
);
allDocuments.add(new Document(
image.getId(),
indexContent,
metadata
));
imageCount++;
}
}
// 3. 索引表格描述
// 表格描述单独传入(通过PdfTableExtractorService提取)
// 这里假设tableChunks已经提取完毕
// (在完整实现中,需要从parseResult或单独传参获取)
// 4. 批量写入向量数据库
if (!allDocuments.isEmpty()) {
// 分批写入,避免单次请求过大
int batchSize = 20;
for (int i = 0; i < allDocuments.size(); i += batchSize) {
List<Document> batch = allDocuments.subList(
i, Math.min(i + batchSize, allDocuments.size())
);
vectorStore.add(batch);
log.debug("已索引 {}/{} 个文档块", Math.min(i + batchSize, allDocuments.size()), allDocuments.size());
}
}
long elapsed = System.currentTimeMillis() - start;
log.info("多模态索引完成:文本{}块,图片{}块,表格{}块,总耗时{}ms",
textCount, imageCount, tableCount, elapsed);
return IndexResult.builder()
.textCount(textCount)
.imageCount(imageCount)
.tableCount(tableCount)
.totalDocuments(allDocuments.size())
.processingTimeMs(elapsed)
.build();
}
@lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class IndexResult {
private int textCount;
private int imageCount;
private int tableCount;
private int totalDocuments;
private long processingTimeMs;
}
}多模态问答:返回答案中引用原始图片
package com.laozhang.ai.multimodal.api;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
/**
* 多模态RAG问答API
* 支持返回包含图片引用的答案
*/
@RestController
@RequestMapping("/api/multimodal")
@RequiredArgsConstructor
@Slf4j
public class MultimodalQaController {
private final VectorStore vectorStore;
private final ChatClient chatClient;
private static final String QA_SYSTEM_PROMPT = """
你是一个设备维修知识库助手,具有丰富的工程技术知识。
回答规则:
1. 基于提供的参考资料回答
2. 如果参考资料包含图片描述,在回答中引用图片时使用格式:{{图片:IMAGE_ID}}
3. 先给出诊断思路,再给出具体操作步骤
4. 涉及参数数值时,务必准确引用原始数据
5. 对于安全相关操作,特别强调注意事项
参考资料:
{context}
""";
@PostMapping("/ask")
public QaResponse ask(@RequestBody QaRequest request) {
long start = System.currentTimeMillis();
String question = request.getQuestion();
// 1. 检索相关内容(文本 + 图片描述 + 表格描述)
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(8)
.similarityThreshold(0.45) // 多模态场景适当降低阈值
.build()
);
if (results.isEmpty()) {
return QaResponse.builder()
.answer("未找到相关资料。请确认问题描述准确,或联系维修工程师。")
.build();
}
// 2. 分类检索结果(文本、图片、表格)
List<Document> textDocs = results.stream()
.filter(d -> !"image".equals(d.getMetadata().get("type")))
.collect(Collectors.toList());
List<Document> imageDocs = results.stream()
.filter(d -> "image".equals(d.getMetadata().get("type")))
.collect(Collectors.toList());
log.info("检索结果:文本{}个,图片{}个", textDocs.size(), imageDocs.size());
// 3. 构建上下文(图片用特殊标记)
String context = buildMultimodalContext(results);
// 4. 生成答案
String rawAnswer = chatClient.prompt()
.system(QA_SYSTEM_PROMPT.replace("{context}", context))
.user(question)
.call()
.content();
// 5. 提取答案中引用的图片
List<ImageReference> imageRefs = extractImageReferences(rawAnswer, imageDocs);
// 6. 替换答案中的图片标记为可访问URL
String processedAnswer = resolveImageReferences(rawAnswer, imageDocs);
return QaResponse.builder()
.answer(processedAnswer)
.imageReferences(imageRefs)
.retrievedDocCount(results.size())
.imageDocCount(imageDocs.size())
.processingTimeMs(System.currentTimeMillis() - start)
.build();
}
/**
* 构建多模态上下文
* 图片内容用特殊格式标注,LLM在引用时使用相同的标记
*/
private String buildMultimodalContext(List<Document> docs) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
Document doc = docs.get(i);
String type = (String) doc.getMetadata().getOrDefault("type", "text");
int page = (int) doc.getMetadata().getOrDefault("page_number", 0);
if ("image".equals(type)) {
String imageId = doc.getId();
sb.append(String.format("[图片资料 - ID:%s,第%d页]\n", imageId, page));
sb.append(doc.getFormattedContent()).append("\n\n");
} else if ("table".equals(type)) {
sb.append(String.format("[表格资料 - 第%d页]\n", page));
sb.append(doc.getFormattedContent()).append("\n\n");
} else {
sb.append(String.format("[文字资料 - 第%d页]\n", page));
sb.append(doc.getFormattedContent()).append("\n\n");
}
}
return sb.toString();
}
/**
* 从答案中提取图片引用
*/
private List<ImageReference> extractImageReferences(
String answer,
List<Document> imageDocs) {
List<ImageReference> refs = new ArrayList<>();
java.util.regex.Pattern pattern =
java.util.regex.Pattern.compile("\\{\\{图片:([^}]+)\\}\\}");
java.util.regex.Matcher matcher = pattern.matcher(answer);
Map<String, Document> imageDocMap = imageDocs.stream()
.collect(Collectors.toMap(Document::getId, d -> d));
while (matcher.find()) {
String imageId = matcher.group(1).trim();
Document imageDoc = imageDocMap.get(imageId);
if (imageDoc != null) {
refs.add(ImageReference.builder()
.imageId(imageId)
.imageUrl((String) imageDoc.getMetadata().get("image_url"))
.pageNumber((int) imageDoc.getMetadata().getOrDefault("page_number", 0))
.build());
}
}
return refs;
}
/**
* 将答案中的图片标记替换为HTML img标签或Markdown格式
*/
private String resolveImageReferences(String answer, List<Document> imageDocs) {
Map<String, Document> imageDocMap = imageDocs.stream()
.collect(Collectors.toMap(Document::getId, d -> d));
java.util.regex.Pattern pattern =
java.util.regex.Pattern.compile("\\{\\{图片:([^}]+)\\}\\}");
java.util.regex.Matcher matcher = pattern.matcher(answer);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String imageId = matcher.group(1).trim();
Document imageDoc = imageDocMap.get(imageId);
String replacement;
if (imageDoc != null) {
String imageUrl = (String) imageDoc.getMetadata().get("image_url");
int page = (int) imageDoc.getMetadata().getOrDefault("page_number", 0);
replacement = String.format("\n\n\n\n", page, imageUrl);
} else {
replacement = "[图片]";
}
matcher.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(replacement));
}
matcher.appendTail(sb);
return sb.toString();
}
// ==================== 图片访问接口 ====================
@GetMapping("/images/{imageId}")
public org.springframework.http.ResponseEntity<byte[]> getImage(
@PathVariable String imageId,
@Value("${multimodal.image.storage-path:/tmp/multimodal-rag/images}") String storagePath) {
try {
java.nio.file.Path imagePath = java.nio.file.Paths.get(storagePath, imageId + ".jpg");
byte[] imageBytes = java.nio.file.Files.readAllBytes(imagePath);
return org.springframework.http.ResponseEntity.ok()
.contentType(org.springframework.http.MediaType.IMAGE_JPEG)
.body(imageBytes);
} catch (Exception e) {
return org.springframework.http.ResponseEntity.notFound().build();
}
}
@lombok.Data @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class QaRequest {
private String question;
}
@lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class QaResponse {
private String answer;
private List<ImageReference> imageReferences;
private int retrievedDocCount;
private int imageDocCount;
private long processingTimeMs;
}
@lombok.Data @lombok.Builder @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public static class ImageReference {
private String imageId;
private String imageUrl;
private int pageNumber;
}
}CLIP模型 vs 分别嵌入:方案对比
| 对比项 | 分别嵌入(本文方案) | CLIP嵌入 |
|---|---|---|
| 实现复杂度 | 低(文字描述→文字向量) | 高(需要部署CLIP模型) |
| 查询方式 | 文字查询→文字向量→文字+图片 | 文字查询→文字向量→图片向量(CLIP对齐) |
| 图片理解深度 | 深(GPT-4o Vision生成丰富描述) | 浅(CLIP的理解能力有限) |
| 成本 | 中(每张图调用一次Vision API) | 低(CLIP模型本地运行) |
| 实时处理 | 支持(实时生成描述) | 支持 |
| 工程图纸质量 | 高(GPT-4o能理解复杂图纸) | 低(CLIP对工程图纸理解差) |
| 自然照片质量 | 高 | 中 |
结论: 对于工程技术文档(含图纸、电路图等专业图表),分别嵌入+Vision模型效果远优于CLIP。CLIP适合的场景是自然图片的图文检索(如电商商品图)。
成本控制:不是所有图片都需要Vision处理
Vision API的成本比文字embedding高10-20倍,需要智能判断哪些图片值得处理:
package com.laozhang.ai.multimodal.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.awt.image.BufferedImage;
/**
* 图片价值评估服务
* 在调用Vision API前,预判图片是否值得处理
*/
@Service
@Slf4j
public class ImageValueAssessorService {
/**
* 评估是否值得调用Vision API处理此图片
*
* @param image 图片
* @param fileBytes 图片字节大小
* @return 评估结果
*/
public ImageAssessment assess(BufferedImage image, long fileBytes) {
// 规则1:尺寸太小 → 通常是图标或装饰,跳过
if (image.getWidth() < 100 || image.getHeight() < 100) {
return ImageAssessment.skip("图片太小,可能是图标");
}
// 规则2:文件字节太小 → 可能是简单线条图,信息量低
if (fileBytes < 5000) {
return ImageAssessment.skip("文件太小,信息量低");
}
// 规则3:纯色或接近纯色图片 → 背景装饰
if (isNearlyMonochrome(image)) {
return ImageAssessment.skip("接近纯色,可能是背景装饰");
}
// 规则4:超大图片 → 需要处理但要压缩
if (image.getWidth() > 2000 || image.getHeight() > 2000) {
return ImageAssessment.processWithResize("超大图片,需要先压缩");
}
// 规则5:图片较大,信息量可能高 → 优先处理
if (fileBytes > 50000) {
return ImageAssessment.processHighPriority("大图,信息量高");
}
return ImageAssessment.process("普通图片");
}
/**
* 简单的图片颜色多样性检测
*/
private boolean isNearlyMonochrome(BufferedImage image) {
// 采样检测:取图片中100个像素点
int sampleSize = 100;
int[] colorValues = new int[sampleSize];
for (int i = 0; i < sampleSize; i++) {
int x = (int) (Math.random() * image.getWidth());
int y = (int) (Math.random() * image.getHeight());
int rgb = image.getRGB(x, y);
// 简化:只看亮度
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
colorValues[i] = (r + g + b) / 3;
}
// 计算标准差
double mean = 0;
for (int v : colorValues) mean += v;
mean /= sampleSize;
double variance = 0;
for (int v : colorValues) variance += Math.pow(v - mean, 2);
variance /= sampleSize;
// 标准差小于20,认为近似纯色
return Math.sqrt(variance) < 20;
}
@lombok.Data
@lombok.AllArgsConstructor
public static class ImageAssessment {
private boolean shouldProcess;
private boolean needsResize;
private boolean highPriority;
private String reason;
public static ImageAssessment skip(String reason) {
return new ImageAssessment(false, false, false, reason);
}
public static ImageAssessment process(String reason) {
return new ImageAssessment(true, false, false, reason);
}
public static ImageAssessment processWithResize(String reason) {
return new ImageAssessment(true, true, false, reason);
}
public static ImageAssessment processHighPriority(String reason) {
return new ImageAssessment(true, false, true, reason);
}
}
}实战效果对比
在小赵的工厂设备手册知识库上,测试了100个维修相关问题:
| 查询类型 | 纯文字RAG | 多模态RAG |
|---|---|---|
| 纯文字问题 | 79% | 82% |
| 需要图纸信息的问题 | 8% | 71% |
| 需要参数表格的问题 | 15% | 78% |
| 综合(需要图文结合) | 12% | 69% |
| 整体平均 | 41% | 74% |
索引成本(以一本200页的设备手册为例):
| 处理项目 | 时间 | 成本(GPT-4o) |
|---|---|---|
| 文本提取+向量化 | 3分钟 | ~$0.5 |
| 图片(共45张)Vision处理 | 25分钟 | ~$2.2 |
| 表格(共18个)描述生成 | 8分钟 | ~$0.4 |
| 合计 | 36分钟 | ~$3.1 |
一本200页的技术手册,处理成本约$3,问答效果提升了33个百分点,非常划算。
生产注意事项
1. 图片存储方案
开发时用本地文件系统,生产环境一定要用对象存储:
# 生产配置:用阿里云OSS
multimodal:
image:
storage-type: oss # local/oss/s3
oss:
bucket: your-bucket-name
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key: ${OSS_ACCESS_KEY}
secret-key: ${OSS_SECRET_KEY}2. Vision API并发限制
GPT-4o的Vision API有速率限制(通常是10 RPM的Vision调用)。务必实现令牌桶限流:
// 使用Resilience4j限流
@RateLimiter(name = "vision-api", fallbackMethod = "fallbackDescription")
public String describeImage(ImageChunk image) {
// 调用Vision API
}3. 增量更新
当手册更新了某几页时,不需要重新处理整个文档。只处理变更的页面,删除旧的向量,写入新的向量:
// 按页面粒度的增量更新
public void updatePage(String docId, int pageNum, byte[] newPageContent) {
// 1. 删除该页原有的所有向量
vectorStore.delete(
FilterExpression.of("doc_id", docId).and("page_number", pageNum)
);
// 2. 重新处理该页并索引
processAndIndexPage(docId, pageNum, newPageContent);
}常见问题解答
Q1:Vision API调用失败怎么处理?
实现降级策略:如果Vision API调用失败,用基础信息(页码、尺寸、文件名)生成一个简单的占位描述,确保图片至少能被基础信息检索到。同时在后台队列里记录失败的图片,等API恢复后重新处理。
Q2:手写字迹的图片能处理吗?
GPT-4o对印刷体文字识别率很高(>95%),对工整手写字迹也不错(>80%),对潦草手写字迹效果较差。对于手写记录为主的场景,建议先用专门的OCR服务(如阿里云OCR)提取文字,再用LLM整理格式。
Q3:图片描述会不会"幻觉"(描述了不存在的内容)?
会,GPT-4o在描述技术图纸时偶尔会"脑补"不存在的数值。建议:一是在提示词中强调"只描述图中明确标注的内容";二是建立审核机制,对关键参数的描述做人工抽检;三是在答案中保留原始图片URL,让用户可以对照原图。
Q4:知识库图片量很大(几千张),处理时间和成本怎么控制?
三个策略:批量处理时用并发控制(concurrency=3-5);用图片价值评估服务过滤掉低价值图片(通常能减少30-40%的处理量);对于非紧急文档,用低峰时段的异步任务处理,避免高峰期的API速率限制。
Q5:如何处理PDF中图文混排(一段文字旁边就是图)的情况?
在解析时记录图片的页面位置(坐标)和相邻文本块的位置,将相邻的图文块关联存储到metadata中。检索时,如果命中了图片块,同时返回相邻的文本块;如果命中了文本块,也返回相邻的图片块。这样可以保留图文的上下文关联。
Q6:表格中有合并单元格怎么处理?
Tabula对合并单元格的处理不是很好,可能会有空格或错位。建议:一是先用LLM预处理CSV,让它修复明显的合并单元格问题;二是对于特别复杂的表格(大量合并单元格),直接用Vision API处理表格图片,而不是用Tabula提取结构化数据。
总结
多模态RAG不是遥远的技术,今天用Spring AI + GPT-4o Vision就能落地。关键是把图文分离处理、统一向量检索这个思路理清楚。
可操作行动清单:
