在构建企业级 RAG(检索增强生成)系统时,纯文本问答已无法满足用户对信息密度和直观性的需求。当用户提问涉及数据趋势、界面布局或复杂流程时,如果系统能像人类专家一样,不仅给出文字结论,还能精准引用并展示关键图表或截图,体验将产生质的飞跃。
然而,工程落地中常面临三大核心挑战:
存储架构:图片数据存在哪?如何避免向量库膨胀?
数据建模:如何设计数据库字段以建立文本切片(Chunk)与图片的精准关联?
关联机制:如何在解析、检索和生成阶段保持这种关联不丢失?
本文将基于 Spring AI Alibaba 和 阿里云 OSS (Object Storage Service),提供一套完整的“解析-存储-索引-检索-渲染”全链路方案,重点补充了数据库表结构设计及阿里云原生 SDK 集成细节。
一、 核心架构设计:为什么图片必须存阿里云 OSS?
很多初学者尝试将图片 Base64 编码后直接存入向量数据库,这是严重的设计误区。
结论:
阿里云 OSS:存储原始图片文件。利用其高持久性和低延迟特性。
向量数据库:只存文本向量和图片元数据(URL、描述、页码)。
关系型数据库 (MySQL):用于维护文档、图片资源、Chunk 之间的复杂映射关系及业务元数据。
二、 数据库与存储详细设计
为了支持高效的图文检索和渲染,我们需要设计三层存储结构。
1. 阿里云 OSS 存储结构
建议按文档 ID 分目录存储,便于管理和清理:
bucket-name/
└── docs/
└── {doc_id}/
├── page_1_img_0.png
├── page_1_img_1.jpg
└── page_5_chart.png2. 关系型数据库设计 (MySQL)
虽然向量库是检索核心,但 MySQL 用于管理资源生命周期和审计。
表 1:文档主表 (doc_info)
记录上传的原始文档信息。
CREATE TABLE doc_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
doc_id VARCHAR(64) NOT NULL UNIQUE, -- 业务唯一ID,如 UUID
file_name VARCHAR(255) NOT NULL, -- 原始文件名
file_type VARCHAR(32), -- PDF, DOCX, etc.
status VARCHAR(32) DEFAULT 'PROCESSING', -- PROCESSING, READY, FAILED
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);表 2:图片资源表 (doc_images)
记录从文档中提取的所有图片及其物理存储位置。
CREATE TABLE doc_images (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
doc_id VARCHAR(64) NOT NULL, -- 关联 doc_info.doc_id
page_num INT NOT NULL, -- 图片所在页码
image_index INT NOT NULL, -- 页内图片序号
oss_key VARCHAR(512) NOT NULL, -- OSS 中的 Key/Path
image_url VARCHAR(1024) NOT NULL, -- 访问 URL (公开或预签名)
image_type VARCHAR(32), -- CHART, SCREENSHOT, PHOTO, UNKNOWN
width INT, -- 图片宽
height INT, -- 图片高
file_size BIGINT, -- 文件大小
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_doc_id (doc_id)
);3. 向量数据库 Metadata 设计
在 Spring AI 的 Document 对象中,Metadata 是连接向量检索与图片资源的桥梁。
三、 技术栈与环境准备
核心框架:Spring AI Alibaba
大语言模型 (LLM):
qwen-plus或qwen-max(用于最终答案生成)多模态模型 (LMM):
qwen-vl-max(用于图片理解、生成描述、图片分类)Embedding 模型:
text-embedding-v2文档解析:Apache PDFBox
对象存储:阿里云 OSS SDK
Maven 依赖
<dependencies>
<!-- Spring AI Alibaba -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M3.2</version>
</dependency>
<!-- PDF 解析 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.29</version>
</dependency>
<!-- 阿里云 OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
</dependencies>
四、 第一阶段:智能解析与入库(Ingestion)
这是最关键的环节。我们需要在解析文档时,识别图片、上传至阿里云 OSS、生成描述,并将这些信息注入到 Vector Store 的 Metadata 中。
1. 阿里云 OSS 服务封装
使用阿里云官方 SDK 进行文件上传,并生成访问 URL。
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.PutObjectRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
@Service
public class AliyunOssService {
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.access-key-id}")
private String accessKeyId;
@Value("${aliyun.oss.access-key-secret}")
private String accessKeySecret;
@Value("${aliyun.oss.bucket-name}")
private String bucketName;
/**
* 上传图片到阿里云 OSS 并返回 URL
*/
public String uploadImage(byte[] imageBytes, String docId, int pageNum, int imgIndex) {
// 1. 创建 OSSClient 实例
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 2. 构建 Object Key (路径)
String fileName = String.format("docs/%s/page_%d_img_%d.png", docId, pageNum, imgIndex);
// 3. 上传文件
PutObjectRequest putObjectRequest = new PutObjectRequest(
bucketName,
fileName,
new ByteArrayInputStream(imageBytes)
);
// 可选:设置 Content-Type
// putObjectRequest.setMetadata(new ObjectMetadata());
ossClient.putObject(putObjectRequest);
// 4. 生成访问 URL
// 如果是私有 Bucket,建议使用 generatePresignedUrl 生成带签名的临时 URL
String url = "https://" + bucketName + "." + endpoint + "/" + fileName;
return url;
} finally {
// 5. 关闭 OSSClient
ossClient.shutdown();
}
}
}注意:生产环境中建议单例化 OSSClient 或使用连接池,避免频繁创建销毁连接。
2. 文档解析与向量化核心逻辑
这里采用“混合策略”:
独立 Chunk:为每张图片创建一个独立的 Chunk,确保检索时能精准命中图片。
元数据注入:将 OSS 返回的 URL 和图片描述存入 Metadata。
@Service
public class DocumentIngestionService {
@Autowired
private VectorStore vectorStore;
@Autowired
private ChatClient vlChatClient; // qwen-vl-max
@Autowired
private AliyunOssService ossService;
@Autowired
private EmbeddingModel embeddingModel; // text-embedding-v2
@Autowired
private JdbcTemplate jdbcTemplate; // 用于写入 MySQL 元数据
public void ingestPdf(File pdfFile, String docId) throws Exception {
PDDocument document = PDDocument.load(pdfFile);
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
PDPage page = document.getPage(pageIndex);
// 1. 提取页面文本(用于周边上下文)
String pageText = new PDFTextStripper().getTextForPage(document, pageIndex + 1);
// 2. 提取页面中的图片
List<byte[]> images = extractImagesFromPage(page);
if (!images.isEmpty()) {
for (int i = 0; i < images.size(); i++) {
byte[] imgData = images.get(i);
// A. 上传阿里云 OSS
String imageUrl = ossService.uploadImage(imgData, docId, pageIndex + 1, i);
// B. 调用 Qwen-VL 生成描述
String description = generateImageDescription(imgData);
// C. 写入 MySQL 图片资源表
saveImageToDb(docId, pageIndex + 1, i, imageUrl, description);
// D. 构建包含图片信息的 Chunk
// 内容组合:图片描述 + 周边文本上下文
String chunkContent = "[图片语义]: " + description + "\n[周边文本]: " + truncateText(pageText, 300);
Map<String, Object> metadata = new HashMap<>();
metadata.put("doc_id", docId);
metadata.put("page_num", pageIndex + 1);
metadata.put("image_url", imageUrl);
metadata.put("image_desc", description);
metadata.put("has_image", true);
metadata.put("source_type", "IMAGE_CHUNK");
// E. 向量化并存入 Vector Store
Document doc = new Document(
chunkContent,
embeddingModel.embed(chunkContent),
metadata
);
vectorStore.add(List.of(doc));
}
} else {
// 纯文本页面,按常规方式切片入库
// ... (常规文本切片逻辑,metadata中 has_image=false)
}
}
document.close();
}
private void saveImageToDb(String docId, int pageNum, int index, String url, String desc) {
String sql = "INSERT INTO doc_images (doc_id, page_num, image_index, oss_key, image_url, image_desc) VALUES (?, ?, ?, ?, ?, ?)";
// 执行插入...
}
private List<byte[]> extractImagesFromPage(PDPage page) throws IOException {
List<byte[]> images = new ArrayList<>();
PDResources resources = page.getResources();
for (COSName name : resources.getXObjectNames()) {
PDXObject xobject = resources.getXObject(name);
if (xobject instanceof PDImageXObject) {
PDImageXObject image = (PDImageXObject) xobject;
images.add(image.getBytes());
}
}
return images;
}
private String generateImageDescription(byte[] imageData) {
// 调用 Qwen-VL 生成描述
Prompt prompt = new Prompt(List.of(
new UserMessage(List.of(
new Media(MediaType.IMAGE_PNG, imageData),
new TextMessage("请详细描述这张图片。如果是图表,总结数据趋势;如果是流程图,解释逻辑。如果是纯文字截图,提取主要文字。")
))
));
return vlChatClient.call(prompt).getResult().getOutput().getContent();
}
private String truncateText(String text, int limit) {
return text.length() > limit ? text.substring(0, limit) + "..." : text;
}
}五、 第二阶段:检索与生成(Retrieval & Generation)
当用户提问时,我们检索相关的 Chunk,并将图片元数据注入 Prompt,指示 LLM 以 Markdown 格式引用图片。
1. 检索服务
@Service
public class RagQueryService {
@Autowired
private VectorStore vectorStore;
@Autowired
private ChatClient chatClient; // qwen-plus
public String query(String userQuestion) {
// 1. 向量检索 Top 5
List<Document> docs = vectorStore.similaritySearch(userQuestion);
// 2. 构建增强型 Context
StringBuilder context = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
Document doc = docs.get(i);
context.append("【参考片段 ").append(i+1).append("】\n");
context.append("内容:").append(doc.getContent()).append("\n");
// 关键:如果元数据中有图片,显式注入线索
if (Boolean.TRUE.equals(doc.getMetadata().get("has_image"))) {
context.append("📷 [关联图片信息]\n")
.append(" - 描述:").append(doc.getMetadata().get("image_desc")).append("\n")
.append(" - 链接:").append(doc.getMetadata().get("image_url")).append("\n");
}
context.append("\n");
}
// 3. 调用 LLM 生成回答
String systemPrompt = """
你是一个智能助手。请根据【参考片段】回答问题。
**重要规则**:
1. 答案必须严格基于参考上下文。
2. 如果某个片段包含“📷 [关联图片信息]”,且该图片对回答问题有帮助,请在回答的相关段落后插入 Markdown 图片语法: ``。
3. 必须使用片段中提供的真实链接,不要捏造。
4. 如果多段内容引用同一张图片,仅插入一次。
""";
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage("参考内容:\n" + context + "\n问题:" + userQuestion)
));
return chatClient.call(prompt).getResult().getOutput().getContent();
}
}LLM 输出示例:
2023年Q4的销售表现非常强劲,销售额达到了500万,同比增长了20%。
这一增长主要得益于华东地区的市场拓展...
六、 第三阶段:前端渲染(Rendering)
前端接收到 Markdown 字符串后,使用标准组件渲染即可。
React 示例:
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
const AnswerRenderer = ({ markdownText }) => (
<div className="prose max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: ({ node, ...props }) => (
<figure className="my-4">
<img
{...props}
alt={props.alt || "Reference Image"}
className="max-w-full h-auto rounded-lg shadow-md hover:shadow-xl transition-shadow cursor-pointer"
loading="lazy"
onClick={() => window.open(props.src, '_blank')}
/>
<figcaption className="text-center text-sm text-gray-500 mt-2">
{props.alt}
</figcaption>
</figure>
)
}}
>
{markdownText}
</ReactMarkdown>
</div>
);七、 进阶优化与避坑指南
1. 避免“图片轰炸”
如果检索到的多个 Chunk 指向同一张图片,LLM 可能会重复插入。
后端去重:在构建 Context 前,对
image_url进行去重,只保留相关性最高的一个 Chunk 携带图片信息。Prompt 约束:明确指示 LLM “如果多段内容引用同一张图片,仅在第一次提及该内容时插入”。
2. 图片相关性重排序 (Rerank)
并非所有检索到的图片都值得展示。
策略:在 LLM 生成前,计算
image_desc与userQuery的语义相似度。只有得分高于阈值(如 0.7)的图片,才将其 URL 放入 Context 中供 LLM 引用。
3. 权限与安全(阿里云 OSS 特有)
私有 Bucket 与签名 URL:
如果文档敏感,OSS Bucket 应设为私有。
在
AliyunOssService中,不要直接拼接 URL,而是使用generatePresignedUrl方法生成带签名的临时 URL。注意:签名 URL 有时效性。如果 RAG 回答被缓存,需确保签名有效期足够长,或者在每次查询时动态刷新签名。
4. 性能优化
异步处理:图片上传和 VLM 描述生成是耗时操作,建议在 Ingestion 阶段使用异步线程池或消息队列处理,避免阻塞主流程。
图片压缩:上传 OSS 前,将图片转换为 WebP 格式或降低分辨率,提升前端加载速度并节省 OSS 流量费用。
八、 总结
实现“图文并茂”的 RAG 系统,核心不在于让 LLM “画”图,而在于精确的元数据管理、规范的数据库表结构设计以及存储分离架构。
通过 Spring AI Alibaba 结合 Qwen-VL 和 阿里云 OSS,我们构建了一条从“图片解析入库”到“智能引用生成”再到“前端优雅渲染”的完整链路。这种方案不仅保留了向量检索的高效性,更极大地增强了 AI 回答的可信度和直观性——毕竟,“有图有真相”。
评论区