이번 프로젝트에서는 이미지를 AI가 검색할 수 있는 데이터로 만드는 과정에 집중하였다. 특히 업로드된 이미지를 Supabase Storage에 저장하고, 이미지 크기를 조정한 뒤 AI에게 이미지 설명을 생성하게 하고, 그 설명과 이미지 URL을 Document의 metadata에 저장하여 VectorStore에 넣는 과정을 실습하였다. 또한 이후 검색 결과를 화면에 전달하기 위해 ImageRagSearchResult DTO를 사용하는 구조도 학습하였다.
이번 프로젝트에서 가장 중요한 흐름은 다음과 같다.
사용자가 이미지 업로드
↓
MultipartFile
↓
Supabase Storage에 실제 이미지 저장
↓
이미지 크기 조정
↓
AI가 이미지 설명 생성
↓
Document 생성
↓
caption + publicUrl을 metadata에 저장
↓
imageVectorStore.add()
즉, 이번 프로젝트에서는 단순히 이미지를 저장하는 것이 아니라 이미지를 검색할 수 있는 형태의 정보로 변환하는 과정을 구현하였다. ImageService에서는 실제 이미지 저장과 이미지 설명 생성, Document 생성, VectorStore 저장까지 하나의 흐름으로 연결되어 있다.
먼저 이번 프로젝트의 핵심 서비스인 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에 저장하고 검색 |
s3Template | Supabase Storage에 이미지 업로드 |
특히 VectorStore에 @Qualifier("imageVectorStore")를 사용한 것이 중요하다.
이번 프로젝트에서는 이미지 검색을 위한 별도의 VectorStore Bean인 imageVectorStore를 사용하고 있기 때문에 해당 이름을 지정하여 주입받는다. ImageRagConfig에서는 이 Bean을 PgVectorStore 기반으로 생성하고, 테이블 이름을 image_vector_store로 지정하였다.
이미지 업로드 요청은 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에 실제 작업을 위임하는 구조를 확인할 수 있었다.
이미지 저장 과정에서 다음 코드가 사용된다.
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
와 같은 형태가 된다.
이렇게 하면 서로 다른 사용자가 같은 이름의 파일을 업로드하더라도 저장 파일명이 겹칠 가능성을 줄일 수 있다.
파일명까지 만들었으면 실제 저장을 수행한다.
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 result
로 받는다.
그리고
System.out.println("result = " + result.getURL());
을 통해 업로드된 파일의 URL을 확인한다.
즉 S3Template이 단순히 저장만 하는 것이 아니라 저장 결과를 S3Resource로 반환하기 때문에 저장된 리소스의 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[]로 반환한다.
크기가 조정된 이미지는 AI가 처리할 수 있도록 Media로 만든다.
Media media = new Media(
MimeTypeUtils.parseMimeType(
file.getContentType()
),
new ByteArrayResource(resized)
);
여기서 중요한 것은 이미지 데이터가
byte[]
에서
ByteArrayResource
를 거쳐
Media
가 된다는 것이다.
즉,
MultipartFile
↓
InputStream
↓
resize()
↓
byte[]
↓
ByteArrayResource
↓
Media
의 흐름으로 이미지가 AI에게 전달할 수 있는 형태로 변환된다.
이번 프로젝트의 핵심 부분이다.
String caption = chatClient.prompt()
.user(u -> u
.text("첨부한 이미지를 해석해주세요")
.media(media))
.call()
.content();
여기서 중요한 것은 단순히 문자열을 AI에게 전달하는 것이 아니라
.media(media)
를 통해 실제 이미지 데이터를 함께 전달한다는 것이다.
결과는
String caption
으로 받는다.
즉
이미지
↓
AI
↓
이미지 설명
↓
caption
이라는 과정이 만들어진다.
그리고 이 caption이 이후 이미지 검색의 핵심 데이터가 된다.
AI가 만들어낸 caption과 Storage의 publicUrl을 Document에 저장한다.
Document document = Document.builder()
.text(caption)
.metadata("caption", caption)
.metadata("publicUrl", publicUrl)
.build();
이미지 설명
을 Document의 본문으로 사용한다.
추가적인 정보를 저장한다.
이번 프로젝트에서는
caption
publicUrl
을 metadata로 저장하였다.
결과적으로 하나의 Document는 개념적으로
Document
├── text
│ └── 이미지 설명
│
└── metadata
├── caption
└── publicUrl
구조를 가지게 된다.
Document를 만들었으면
imageVectorStore.add(
List.of(document)
);
를 실행한다.
이 부분을 통해 이미지가 단순한 파일 저장으로 끝나는 것이 아니라 검색 가능한 데이터로 등록된다.
전체적으로 보면
이미지 업로드
↓
Storage 저장
↓
이미지 설명 생성
↓
Document 생성
↓
VectorStore 저장
이다.
이것이 이번 프로젝트에서 구현한 이미지 RAG 데이터 적재 과정이다.
검색 결과를 화면에서 사용하기 위해 별도의 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를 가져온다.
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
만 전달된다.
이번 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
↓
화면 출력
으로 이어진다.
| 새롭게 배운 내용 | 학습한 내용 |
|---|---|
S3Template | Supabase Storage에 이미지를 업로드하는 방법 |
S3Resource | Storage 업로드 결과와 URL을 다루는 방법 |
| UUID 파일명 | 업로드 파일에 새로운 이름을 생성하는 방법 |
| Public URL 변환 | Storage URL을 public object URL 형태로 변환하는 방법 |
| Thumbnailator | 이미지를 512×512 크기로 변환하는 방법 |
Media | 처리한 이미지 데이터를 AI 요청에 포함시키는 방법 |
Document | 이미지 설명과 추가 정보를 RAG 데이터로 구성하는 방법 |
| Document metadata | caption, 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());
↓
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);
↓
String caption = chatClient.prompt()
.user(u -> u
.text("첨부한 이미지를 해석해주세요")
.media(media))
.call()
.content();
↓
Document document = Document.builder()
.text(caption)
.metadata("caption", caption)
.metadata("publicUrl", publicUrl)
.build();
↓
imageVectorStore.add(List.of(document));
이 6단계를 이해하면 이번 프로젝트에서 이미지가 어떻게 RAG 데이터로 변환되는지를 이해한 것이다.
이번 프로젝트에서 새롭게 배운 가장 중요한 부분은 이미지를 단순히 저장하는 것과 AI가 검색할 수 있는 데이터로 만드는 것은 다르다는 것이었다.
이미지는 먼저 S3Template을 통해 Supabase Storage에 저장하고, Thumbnailator를 이용해 AI에 전달할 이미지를 처리한다. 이후 Media를 통해 AI에게 이미지를 전달하여 caption을 생성하고, 이 설명과 publicUrl을 Document의 metadata에 저장한다. 마지막으로 imageVectorStore.add()를 통해 Document를 VectorStore에 저장함으로써 이후 검색할 수 있는 이미지 RAG 데이터가 만들어진다.
즉 이번 Part의 핵심은
이미지 → 저장 → 전처리 → AI 설명 → Document + Metadata → VectorStore
라는 하나의 파이프라인을 이해하는 것이다.