26/08/10 IL - (1)-1 이미지 RAG를 위한 이미지 저장·전처리·AI 분석·VectorStore 저장

Let's take a break·2026년 8월 19일

이미지 RAG를 위한 이미지 저장·전처리·AI 분석·VectorStore 저장

이번 프로젝트에서는 이미지를 AI가 검색할 수 있는 데이터로 만드는 과정에 집중하였다. 특히 업로드된 이미지를 Supabase Storage에 저장하고, 이미지 크기를 조정한 뒤 AI에게 이미지 설명을 생성하게 하고, 그 설명과 이미지 URL을 Document의 metadata에 저장하여 VectorStore에 넣는 과정을 실습하였다. 또한 이후 검색 결과를 화면에 전달하기 위해 ImageRagSearchResult DTO를 사용하는 구조도 학습하였다.


이미지 RAG에서 이미지 자체를 그대로 저장하지 않는 이유

이번 프로젝트에서 가장 중요한 흐름은 다음과 같다.

사용자가 이미지 업로드
        ↓
MultipartFile
        ↓
Supabase Storage에 실제 이미지 저장
        ↓
이미지 크기 조정
        ↓
AI가 이미지 설명 생성
        ↓
Document 생성
        ↓
caption + publicUrl을 metadata에 저장
        ↓
imageVectorStore.add()

즉, 이번 프로젝트에서는 단순히 이미지를 저장하는 것이 아니라 이미지를 검색할 수 있는 형태의 정보로 변환하는 과정을 구현하였다. ImageService에서는 실제 이미지 저장과 이미지 설명 생성, Document 생성, VectorStore 저장까지 하나의 흐름으로 연결되어 있다.


ImageService의 구성

먼저 이번 프로젝트의 핵심 서비스인 ImageService를 살펴보았다.

@Service
@RequiredArgsConstructor
public class ImageService {

    @Qualifier("googleGenAiChatModel")
    private final ChatModel chatModel;

    @Qualifier("imageVectorStore")
    private final VectorStore imageVectorStore;

    // Supabase Storage
    private final S3Template s3Template;
}

여기에서 중요한 것은 세 가지 의존성이다.

객체역할
chatModel업로드된 이미지를 해석하여 설명을 생성
imageVectorStore이미지 설명과 metadata를 VectorStore에 저장하고 검색
s3TemplateSupabase Storage에 이미지 업로드

특히 VectorStore@Qualifier("imageVectorStore")를 사용한 것이 중요하다.

이번 프로젝트에서는 이미지 검색을 위한 별도의 VectorStore Bean인 imageVectorStore를 사용하고 있기 때문에 해당 이름을 지정하여 주입받는다. ImageRagConfig에서는 이 Bean을 PgVectorStore 기반으로 생성하고, 테이블 이름을 image_vector_store로 지정하였다.


이미지 업로드 Controller

이미지 업로드 요청은 ImageController에서 받는다.

@PostMapping
public String uploadImage(@RequestParam MultipartFile file,
                          RedirectAttributes redirectAttributes) {

    System.out.println("file = " + file);
    System.out.println(file.getContentType());
    System.out.println(file.getOriginalFilename());

    redirectAttributes.addFlashAttribute("msg", "파일 업로드 완료");

    String answer = imageService.explain(file);

    redirectAttributes.addFlashAttribute("answer", answer);

    return "redirect:/image";
}

사용자가 이미지 파일을 업로드하면

@RequestParam MultipartFile file

을 통해 파일을 전달받는다.

그리고 Controller에서는 직접 파일을 저장하거나 AI 분석을 수행하지 않고

String answer = imageService.explain(file);

을 호출한다.

즉 역할은 다음과 같이 나뉜다.

계층역할
Controller요청을 받고 Service 호출
ImageService이미지 저장 및 AI 분석
Storage실제 이미지 저장
VectorStore검색을 위한 데이터 저장

Controller에서 모든 작업을 처리하지 않고 Service에 실제 작업을 위임하는 구조를 확인할 수 있었다.


UUID로 이미지 파일명 생성

이미지 저장 과정에서 다음 코드가 사용된다.

String uuid = java.util.UUID.randomUUID().toString();

String extension =
        StringUtils.getFilenameExtension(
                file.getOriginalFilename());

String newFilename =
        uuid + "." + extension;

사용자가 업로드한 파일 이름이

cat.jpg

라고 하더라도 그대로 저장하지 않는다.

먼저

UUID.randomUUID().toString()

으로 UUID를 생성한다.

그리고

StringUtils.getFilenameExtension(
    file.getOriginalFilename()
)

으로 원본 파일의 확장자를 가져온다.

결과적으로

UUID + "." + jpg

형태의 새로운 파일명이 만들어진다.

예를 들어 개념적으로

cat.jpg
        ↓
UUID 생성
        ↓
550e8400-....jpg

와 같은 형태가 된다.

이렇게 하면 서로 다른 사용자가 같은 이름의 파일을 업로드하더라도 저장 파일명이 겹칠 가능성을 줄일 수 있다.


Supabase Storage에 이미지 저장

파일명까지 만들었으면 실제 저장을 수행한다.

S3Resource result =
        s3Template.upload(
                "rag",
                newFilename,
                file.getInputStream()
        );

여기서 새롭게 학습한 부분은 S3Template이다.

프로젝트에서는 Supabase Storage를 S3 방식으로 다루고 있다.

S3Template
   ↓
Supabase Storage
   ↓
rag 버킷
   ↓
newFilename

"rag"는 업로드할 Storage 영역이고,

newFilename은 실제 저장할 파일 이름이다.

file.getInputStream()은 업로드된 파일의 내용을 읽어 Storage로 전달한다.

따라서 이 코드 한 줄을 통해

사용자가 업로드한 MultipartFile → Supabase Storage

로 실제 파일이 이동한다.


S3Resource를 이용한 저장 결과 확인

업로드 결과는

S3Resource result

로 받는다.

그리고

System.out.println("result = " + result.getURL());

을 통해 업로드된 파일의 URL을 확인한다.

S3Template이 단순히 저장만 하는 것이 아니라 저장 결과를 S3Resource로 반환하기 때문에 저장된 리소스의 URL을 가져올 수 있다는 점을 학습하였다.


Public URL 생성

그런데 코드에서는 result.getURL()을 그대로 사용하는 것이 아니라 URL을 변경한다.

String publicUrl = result.getURL().toString()
        .replace(
                "storage.supabase.co/storage/v1/s3",
                "supabase.co/storage/v1/object/public"
        );

프로젝트 코드에는 실제 URL 형태가 주석으로 작성되어 있다.

https://...storage.supabase.co/storage/v1/s3/rag/movie.jpg

https://...supabase.co/storage/v1/object/public/rag/movie.jpg

형태로 변환한다.

즉,

S3 방식의 URL
       ↓
public URL 형태로 변환

하는 것이다.

이렇게 만들어진 publicUrl은 나중에 Document의 metadata에 저장된다.


이미지 크기 조정

이미지를 AI에게 전달하기 전에 다음 코드를 실행한다.

byte[] resized = resize(
        file.getInputStream(),
        file.getContentType().split("/")[1],
        512,
        512
);

여기에서 새롭게 사용한 것이 Thumbnailator이다.

public byte[] resize(
        InputStream inputStream,
        String format,
        int width,
        int height
) {

    ByteArrayOutputStream outputStream =
            new ByteArrayOutputStream();

    try {
        Thumbnails.of(inputStream)
                .size(width, height)
                .outputFormat(format)
                .toOutputStream(outputStream);

    } catch (Exception e) {
        throw new IllegalArgumentException(e);
    }

    return outputStream.toByteArray();
}

처리 흐름은 다음과 같다.

InputStream
    ↓
Thumbnailator
    ↓
512 × 512
    ↓
ByteArrayOutputStream
    ↓
byte[]

Thumbnails.of(inputStream)으로 원본 이미지 스트림을 받고,

.size(width, height)

를 통해 크기를 설정한다.

이번 코드에서는 실제 호출할 때

512 × 512

를 전달한다.

마지막에는

outputStream.toByteArray()

를 이용하여 처리된 이미지를 byte[]로 반환한다.


Media 객체로 이미지 전달

크기가 조정된 이미지는 AI가 처리할 수 있도록 Media로 만든다.

Media media = new Media(
        MimeTypeUtils.parseMimeType(
                file.getContentType()
        ),
        new ByteArrayResource(resized)
);

여기서 중요한 것은 이미지 데이터가

byte[]

에서

ByteArrayResource

를 거쳐

Media

가 된다는 것이다.

즉,

MultipartFile
      ↓
InputStream
      ↓
resize()
      ↓
byte[]
      ↓
ByteArrayResource
      ↓
Media

의 흐름으로 이미지가 AI에게 전달할 수 있는 형태로 변환된다.


AI에게 이미지 설명 생성

이번 프로젝트의 핵심 부분이다.

String caption = chatClient.prompt()
        .user(u -> u
                .text("첨부한 이미지를 해석해주세요")
                .media(media))
        .call()
        .content();

여기서 중요한 것은 단순히 문자열을 AI에게 전달하는 것이 아니라

.media(media)

를 통해 실제 이미지 데이터를 함께 전달한다는 것이다.

결과는

String caption

으로 받는다.

이미지
  ↓
AI
  ↓
이미지 설명
  ↓
caption

이라는 과정이 만들어진다.

그리고 이 caption이 이후 이미지 검색의 핵심 데이터가 된다.


Document에 이미지 설명과 URL 저장

AI가 만들어낸 caption과 Storage의 publicUrlDocument에 저장한다.

Document document = Document.builder()
        .text(caption)
        .metadata("caption", caption)
        .metadata("publicUrl", publicUrl)
        .build();

text

이미지 설명

을 Document의 본문으로 사용한다.

metadata

추가적인 정보를 저장한다.

이번 프로젝트에서는

caption
publicUrl

을 metadata로 저장하였다.

결과적으로 하나의 Document는 개념적으로

Document

├── text
│    └── 이미지 설명
│
└── metadata
     ├── caption
     └── publicUrl

구조를 가지게 된다.


VectorStore에 Document 저장

Document를 만들었으면

imageVectorStore.add(
        List.of(document)
);

를 실행한다.

이 부분을 통해 이미지가 단순한 파일 저장으로 끝나는 것이 아니라 검색 가능한 데이터로 등록된다.

전체적으로 보면

이미지 업로드
      ↓
Storage 저장
      ↓
이미지 설명 생성
      ↓
Document 생성
      ↓
VectorStore 저장

이다.

이것이 이번 프로젝트에서 구현한 이미지 RAG 데이터 적재 과정이다.


ImageRagSearchResult DTO

검색 결과를 화면에서 사용하기 위해 별도의 DTO를 사용한다.

public record ImageRagSearchResult(
        String caption,
        String publicUrl
) {
}

이 DTO는 검색 결과에서 필요한

이미지 설명

+

이미지 URL

만 전달하기 위한 객체이다.

즉 VectorStore 내부의 Document를 그대로 Controller나 View에 전달하는 것이 아니라, 화면에서 필요한 데이터만 DTO로 변환한다.


이미지 검색 과정

이미지를 저장했다면 다음으로 저장된 이미지 중에서 질문과 관련된 이미지를 검색할 수 있다.

프로젝트에서는 다음 코드를 사용하였다.

SearchRequest request = SearchRequest.builder()
        .query(query)
        .topK(3)
        .similarityThreshold(0.5)
        .build();

List<Document> results =
        imageVectorStore.similaritySearch(request);

이번 Part에서는 검색 원리 자체보다는 저장한 Document를 어떻게 검색 요청으로 연결하는지에 초점을 맞추었다.

여기서

.query(query)

는 사용자가 입력한 검색어이고,

.topK(3)

은 최대 3개의 결과를 가져오도록 설정한다.

.similarityThreshold(0.5)

는 유사도가 0.5 이상인 결과를 대상으로 한다.

검색 결과는

List<Document> results

로 반환된다.


Document의 metadata를 검색 결과 DTO로 변환

검색된 Document에서 metadata를 가져온다.

return results.stream().map(
        d -> {
            Object caption =
                    d.getMetadata().get("caption");

            Object publicUrl =
                    d.getMetadata().get("publicUrl");

            return new ImageRagSearchResult(
                    caption.toString(),
                    publicUrl.toString()
            );
        }
).toList();

검색 결과인 Document에는 우리가 앞에서 저장했던

caption
publicUrl

이 들어 있다.

그래서

d.getMetadata().get("caption")

으로 이미지 설명을 가져오고,

d.getMetadata().get("publicUrl")

로 실제 이미지 URL을 가져온다.

그리고 이를

new ImageRagSearchResult(...)

로 변환한다.

최종적으로 화면에는

검색된 이미지의 설명

+

실제 이미지 URL

만 전달된다.


이미지 RAG 전체 흐름

이번 Part에서 가장 중요하게 기억해야 할 흐름이다.

[이미지 업로드]

MultipartFile
      ↓
UUID 파일명 생성
      ↓
S3Template
      ↓
Supabase Storage
      ↓
publicUrl 생성
      ↓
이미지 resize
      ↓
Media 생성
      ↓
AI 이미지 분석
      ↓
caption 생성
      ↓
Document 생성
      ↓
caption + publicUrl metadata 저장
      ↓
imageVectorStore.add()
      ↓
VectorStore

그리고 검색할 때는

사용자 query
      ↓
SearchRequest
      ↓
imageVectorStore.similaritySearch()
      ↓
Document 검색
      ↓
metadata에서 caption / publicUrl 추출
      ↓
ImageRagSearchResult
      ↓
화면 출력

으로 이어진다.


새롭게 배운 내용

새롭게 배운 내용학습한 내용
S3TemplateSupabase Storage에 이미지를 업로드하는 방법
S3ResourceStorage 업로드 결과와 URL을 다루는 방법
UUID 파일명업로드 파일에 새로운 이름을 생성하는 방법
Public URL 변환Storage URL을 public object URL 형태로 변환하는 방법
Thumbnailator이미지를 512×512 크기로 변환하는 방법
Media처리한 이미지 데이터를 AI 요청에 포함시키는 방법
Document이미지 설명과 추가 정보를 RAG 데이터로 구성하는 방법
Document metadatacaption, publicUrl 같은 추가 정보를 Document에 저장하는 방법
imageVectorStore이미지 전용 VectorStore를 구성하여 데이터를 저장·검색하는 구조
SearchRequest검색어, 최대 결과 수, 유사도 기준을 지정하는 방법
ImageRagSearchResult검색 결과에서 화면에 필요한 데이터만 DTO로 전달하는 방법

핵심 코드

이번 프로젝트에서 가장 중요한 코드는 다음 흐름으로 기억하면 된다.

① 이미지 저장

String uuid = java.util.UUID.randomUUID().toString();

String extension =
        StringUtils.getFilenameExtension(
                file.getOriginalFilename());

String newFilename = uuid + "." + extension;

S3Resource result =
        s3Template.upload(
                "rag",
                newFilename,
                file.getInputStream());

② Public URL 생성

String publicUrl = result.getURL().toString()
        .replace(
                "storage.supabase.co/storage/v1/s3",
                "supabase.co/storage/v1/object/public");

③ 이미지 전처리

byte[] resized = resize(
        file.getInputStream(),
        file.getContentType().split("/")[1],
        512,
        512);

④ AI 이미지 설명 생성

String caption = chatClient.prompt()
        .user(u -> u
                .text("첨부한 이미지를 해석해주세요")
                .media(media))
        .call()
        .content();

⑤ RAG Document 생성

Document document = Document.builder()
        .text(caption)
        .metadata("caption", caption)
        .metadata("publicUrl", publicUrl)
        .build();

⑥ VectorStore 저장

imageVectorStore.add(List.of(document));

이 6단계를 이해하면 이번 프로젝트에서 이미지가 어떻게 RAG 데이터로 변환되는지를 이해한 것이다.


Part 1-1 핵심 정리

이번 프로젝트에서 새롭게 배운 가장 중요한 부분은 이미지를 단순히 저장하는 것과 AI가 검색할 수 있는 데이터로 만드는 것은 다르다는 것이었다.

이미지는 먼저 S3Template을 통해 Supabase Storage에 저장하고, Thumbnailator를 이용해 AI에 전달할 이미지를 처리한다. 이후 Media를 통해 AI에게 이미지를 전달하여 caption을 생성하고, 이 설명과 publicUrlDocument의 metadata에 저장한다. 마지막으로 imageVectorStore.add()를 통해 Document를 VectorStore에 저장함으로써 이후 검색할 수 있는 이미지 RAG 데이터가 만들어진다.

즉 이번 Part의 핵심은

이미지 → 저장 → 전처리 → AI 설명 → Document + Metadata → VectorStore

라는 하나의 파이프라인을 이해하는 것이다.

0개의 댓글