侧边栏壁纸
博主头像
牧云

怀璧慎显,博识谨言。

  • 累计撰写 168 篇文章
  • 累计创建 16 个标签
  • 累计收到 8 条评论

目 录CONTENT

文章目录

Spring AI Alibaba 实战:构建“图文并茂”的 RAG 系统

秋之牧云
2026-04-21 / 0 评论 / 0 点赞 / 2 阅读 / 0 字

在构建企业级 RAG(检索增强生成)系统时,纯文本问答已无法满足用户对信息密度和直观性的需求。当用户提问涉及数据趋势、界面布局或复杂流程时,如果系统能像人类专家一样,不仅给出文字结论,还能精准引用并展示关键图表或截图,体验将产生质的飞跃。

然而,工程落地中常面临三大核心挑战:

  1. 存储架构:图片数据存在哪?如何避免向量库膨胀?

  2. 数据建模:如何设计数据库字段以建立文本切片(Chunk)与图片的精准关联?

  3. 关联机制:如何在解析、检索和生成阶段保持这种关联不丢失?

本文将基于 Spring AI Alibaba阿里云 OSS (Object Storage Service),提供一套完整的“解析-存储-索引-检索-渲染”全链路方案,重点补充了数据库表结构设计及阿里云原生 SDK 集成细节。


一、 核心架构设计:为什么图片必须存阿里云 OSS?

很多初学者尝试将图片 Base64 编码后直接存入向量数据库,这是严重的设计误区

维度

存入向量库/DB (Base64)

存入阿里云 OSS + DB存URL

存储成本

极高(Base64 体积膨胀 33%,向量库存储昂贵)

极低(OSS 专为非结构化数据设计,按量付费)

检索性能

拖慢向量检索,增加内存压力

向量库只存轻量级文本和 URL 元数据,极速

扩展性

难以支持 CDN 加速、权限控制

天然支持 CDN、签名 URL、生命周期管理、高可用

最佳实践

❌ 不推荐

行业标准

结论

  • 阿里云 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.png

2. 关系型数据库设计 (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 是连接向量检索与图片资源的桥梁。

Key

Value 示例

说明

doc_id

"doc_12345"

文档唯一标识,用于过滤和溯源

chunk_id

"chunk_abcde"

Chunk 唯一标识

page_num

5

图片所在页码

image_url

"https://bucket.oss-cn-hangzhou.aliyuncs.com/docs/doc_12345/page_5_chart.png"

核心字段:图片访问地址

image_desc

"2023年Q4销售趋势柱状图"

VLM/OCR 生成的描述,用于辅助 LLM 判断是否引用

has_image

true

布尔标记,快速判断该 Chunk 是否含图

source_type

"IMAGE_CHUNK"

区分是纯文本切片还是图片专用切片


三、 技术栈与环境准备

  • 核心框架:Spring AI Alibaba

  • 大语言模型 (LLM)qwen-plusqwen-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. 文档解析与向量化核心逻辑

这里采用“混合策略”

  1. 独立 Chunk:为每张图片创建一个独立的 Chunk,确保检索时能精准命中图片。

  2. 元数据注入:将 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_descuserQuery 的语义相似度。只有得分高于阈值(如 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 回答的可信度和直观性——毕竟,“有图有真相”。

0

评论区