프레임워크/Spring

[Spring Boot] Spring AI와 Vertex API로 RAG 구현하기

munsik22 2026. 6. 10. 22:21

PGVector 설치 및 설정

PGVector는 PostgreSQL의 오픈 소스 확장 기능으로, 벡터 데이터를 컬럼 타입으로 지원한다.

  • docker-compose.yml
services:
  pgvector:
    image: pgvector/pgvector:pg17
    container_name: pgvector
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgress
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - pg-network

volumes:
  pgdata:

networks:
  pg-network:
    driver: bridge

  • build.gradle
implementation 'org.springframework.ai:spring-ai-starter-model-vertex-ai-embedding'
implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector'
  • application.yml
spring:

  # PGVector
  datasource:
    url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres

  # 정적 리소스 캐시 사용 안함 설정
  web:
    resources:
      cache:
        cachecontrol:
          no-cache: true
          no-store: true
          must-revalidate: true

  ai:
    vertex:
      ai:
        embedding:
          project-id: ${GCP_PROJECT_ID}
          location: asia-northeast3
          text:
            options:
              model: gemini-embedding-001
              dimensions: 1536

        gemini:
          # ...

    vectorstore:
      pgvector:
        initialize-schema: true # 테이블 자동 구성 여부
        schema-name: public  # 사용할 DB 내부 스키마 이름
        table-name: vector_store # 사용할 테이블 이름
        dimensions: 1536 # 저장할 Vector 차원 수
  • 앱 실행 시 vector_store 테이블이 생성된다.
컬럼명 타입 설명
id uuid 기본키 (PK), 자동으로 UUID 생성
content text 원문 텍스트 데이터
metadata json 메타데이터 (출처, 페이지 번호 등)
embedding vector(1536) 임베딩 모델을 통해 생성된 벡터 데이터

  • VertexAiConfig
@Configuration
public class VertexAiConfig {

    // ...

    @Value("${spring.ai.vertex.ai.embedding.project-id}")
    private String embeddingProjectId;

    @Value("${spring.ai.vertex.ai.embedding.location}")
    private String embeddingLocation;

    // ...

    @Bean
    public VertexAiEmbeddingConnectionDetails connectionDetails() throws IOException {
        byte[] decodedKey = Base64.getDecoder().decode(base64Credentials.trim());

        GoogleCredentials credentials = GoogleCredentials.fromStream(new ByteArrayInputStream(decodedKey))
                .createScoped(Collections.singletonList("https://www.googleapis.com/auth/cloud-platform"));

        PredictionServiceSettings settings = PredictionServiceSettings.newBuilder()
                .setEndpoint(embeddingLocation + "-aiplatform.googleapis.com:443")
                .setCredentialsProvider(FixedCredentialsProvider.create(credentials))
                .build();

        return new VertexAiEmbeddingConnectionDetails(embeddingProjectId, embeddingLocation, "google", settings);
    }
}

임베딩 테스트

Gemini 2.5 Flash 모델의 Cut-Off는 2025년 1월이므로, 검색 기능을 활성화하지 않는 한 Gemini는 2026년 2월 말에 시작된 미국-이란 전쟁에 대해 알지 못한다.

  • EmbeddingController
더보기
@RestController
public class EmbeddingController {

    private final VectorStore vectorStore;

    public EmbeddingController(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    @GetMapping(value = "/embed")
    void embed() {
        List<Document> documents = List.of(
                new Document(
                        "2026년 미국-이란 전쟁 개요: 2026년 2월 28일, 미국 트럼프 대통령의 명령으로 'Operation Epic Fury' 작전이 개시되었다. " +
                                "미국과 이스라엘은 이란의 핵무기 개발 저지와 반정부 세력 지원을 명분으로 선전포고 없이 이란에 대한 기습 선제 타격을 감행했다. " +
                                "CENTCOM은 UTC 기준 06:35에 이란에 대한 공습을 시작했다고 발표했으며, " +
                                "미 해군 함정이 토마호크 미사일을 발사하고 B-2 스텔스 폭격기, B-1 랜서, B-52 등이 이란의 강화된 탄도미사일 시설을 타격했다.",
                        Map.of("category", "전쟁개요", "date", "2026-02-28", "source", "wikipedia")
                ),
                new Document(
                        "2026년 이란 전쟁 이스라엘 작전 및 이란 지도부 제거: 2026년 2월 28일 UTC 06:45, " +
                                "이스라엘 공군은 전례 없는 참수 작전을 개시했다. 이란 최고지도자 알리 하메네이가 자신의 거주 단지에서 회의 중 사망했으며, " +
                                "여러 고위 관리들과 그의 가족들도 함께 사망했다. " +
                                "또한 미국 잠수함이 스리랑카 갈레 해안에서 이란 군함 IRIS Dena를 격침시켜 승조원 87명이 사망했다. " +
                                "해당 함정은 인도 비사카파트남에서 개최된 국제 함대 사열 2026에서 귀환하던 중이었다.",
                        Map.of("category", "군사작전", "date", "2026-02-28", "source", "wikipedia")
                ),
                new Document(
                        "2026년 이란 전쟁 이란의 반격: 이란은 이스라엘 본토와 미군이 주둔한 바레인, 아랍에미리트, 쿠웨이트 등에 대한 반격을 개시했다. " +
                                "2026년 3월 5일, 이란 군사 소식통은 파르스 통신을 통해 2월 28일 이후 500발 이상의 탄도미사일 및 순항미사일과 " +
                                "약 2,000대의 드론을 발사했다고 밝혔다. " +
                                "이란은 또한 호르무즈 해협을 봉쇄하여 국제 원유 수송에 큰 차질을 빚었으며, 서아시아 전역이 전쟁에 연루되었다.",
                        Map.of("category", "이란반격", "date", "2026-03-05", "source", "wikipedia")
                ),
                new Document(
                        "2026년 이란 전쟁 민간인 피해 및 인프라 타격: 2026년 3월 3일, 이란 적신월사는 600명 이상의 민간인이 사망했다고 발표했으며, " +
                                "이란 인권 활동가들은 742명의 민간인이 사망한 것으로 추정했다. " +
                                "이란의 한 학교 건물이 미사일 공격을 받아 초등학생 100명 이상과 50~100명의 인원이 사망했다. " +
                                "3월 7일, 적신월사는 미국-이스라엘 공격으로 6,668개 이상의 민간 시설이 표적이 되었다고 보고했다. " +
                                "사이버 공격으로 이란 내 인터넷이 거의 완전히 마비되어 60시간 이상 연결률이 평소의 1~4% 수준으로 떨어졌으며, " +
                                "정부 통신, 국영 미디어, 공공 서비스가 중단되었다.",
                        Map.of("category", "민간인피해", "date", "2026-03-07", "source", "적신월사")
                ),
                new Document(
                        "2026년 이란 전쟁 휴전 합의: 약 5주간의 전쟁 끝에 2026년 4월 7일 조건부 휴전이 극적으로 합의되었다. " +
                                "이란은 호르무즈 해협을 재개방하기로 합의했다. " +
                                "4월 21일, 트럼프 대통령은 휴전을 무기한 연장했으나 이후에도 양측의 휴전 위반 사례가 지속되었다. " +
                                "이 휴전은 단순한 외교적 타협이 아닌 국제 구조, 국내 제도, 중견국 중재가 복합적으로 작용한 결과였다.",
                        Map.of("category", "휴전협상", "date", "2026-04-07", "source", "wikipedia")
                ),
                new Document(
                        "2026년 이란 전쟁 이후 핵 협상 및 평화 협상: 2026년 5월 28일, 미국과 이란 협상단은 " +
                                "휴전 연장 및 이란 핵 프로그램 협상을 위한 60일 양해각서(MOU) 합의에 도달했으나 트럼프 대통령의 최종 승인은 미정이었다. " +
                                "MOU에는 호르무즈 해협의 무제한 통항 보장, 이란의 30일 내 해협 내 기뢰 제거, " +
                                "이란의 핵 농축 모라토리엄 및 고농축 우라늄 처리 방안 등이 포함되었다. " +
                                "그러나 이란 측은 호르무즈 해협 지위, 핵 프로그램, 레바논 친이란 무장세력 관련 핵심 쟁점에서 " +
                                "여전히 큰 이견이 남아 있다고 밝혔다.",
                        Map.of("category", "핵협상", "date", "2026-05-28", "source", "al-jazeera")
                )
        );

        vectorStore.add(documents);

        System.out.println("총 " + documents.size() + "개의 문서가 VectorStore에 임베딩되었습니다.");
    }
}

/embed 호출 후 텍스트 문서들이 VectorStore에 임베딩된 모습

RAG 파이프라인 구축하기

  • build.gradle
implementation 'org.springframework.ai:spring-ai-advisors-vector-store'
  • 기본 요청 DTO
public record ChatRequest(String message) {
}
  • 기본 응답 DTO
public record ChatResponse(String answer) {
}
  • RagController
@RestController
@RequestMapping("/api/rag")
public class RagController {

    private final RagChatService ragChatService;

    public RagController(RagChatService ragChatService) {
        this.ragChatService = ragChatService;
    }

    @PostMapping
    public ChatResponse ask(@RequestBody ChatRequest request) {
        return new ChatResponse(ragChatService.askWithDocuments(request.message()));
    }
}
  • RagChatService
@Service
public class RagChatService {

    private final ChatClient chatClient;

    public RagChatService(ChatClient.Builder builder, VectorStore vectorStore) {
        QuestionAnswerAdvisor questionAnswerAdvisor = QuestionAnswerAdvisor
                .builder(vectorStore)
                .build();

        this.chatClient = builder
                .defaultSystem("""
                        제공된 문서 컨텍스트를 우선하여 답변하세요.
                        문서에서 근거를 찾을 수 없으면 모른다고 답하세요.
                        답변 마지막에는 참고한 근거를 짧게 요약하세요.
                        """)
                .defaultAdvisors(questionAnswerAdvisor)
                .build();
    }

    public String askWithDocuments(String question) {
        return chatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}

RAG 테스트

  • RAG 적용하지 않은 경우: 2026년의 데이터가 없기 때문에 과거의 이야기를 하고 있다.

  • RAG를 적용한 경우: 임베딩한 문서를 바탕으로 2026년의 정보를 제공한다.

 

GitHub - qkrwns1478/ai-demo: Spring AI 실습

Spring AI 실습. Contribute to qkrwns1478/ai-demo development by creating an account on GitHub.

github.com