복잡한뇌구조마냥

[Spring] Spring AI ( OpenAI + Rag + MariaDB Vector ) 본문

BE/Spring

[Spring] Spring AI ( OpenAI + Rag + MariaDB Vector )

지금해냥 2025. 11. 18. 19:04

1. Spring AI란?

Spring AI는 Spring 진영에서 만든 “AI 연동용 추상화 프레임워크”다.
OpenAI, Azure OpenAI, Anthropic, Ollama 등 다양한 모델을 하나의 공통 API(ChatModel / ChatClient / EmbeddingModel 등) 로 다룰 수 있게 해준다. 

핵심 포인트는:

  • ChatModel / ChatClient 로 LLM 호출
  • EmbeddingModel 로 임베딩 추출
  • VectorStore/직접 구현으로 벡터 검색
  • Spring Boot Auto-Configuration 지원 (config 최소화) 

2. 의존성 & 기본 설정

2-1. Gradle 의존성

dependencies {
    // OpenAI Chat (ChatClient / ChatModel)
    implementation("org.springframework.ai:spring-ai-starter-model-openai") // Chat

    // OpenAI Embedding
    implementation("org.springframework.ai:spring-ai-openai")               // Embedding

    // RAG 유틸 (Retriever / Pipeline 등)
    implementation("org.springframework.ai:spring-ai-rag")

    // MariaDB 기반 VectorStore
    implementation("org.springframework.ai:spring-ai-starter-vector-store-mariadb")

    // MCP(Server) – IDE 등 외부 툴에서 AI 기능 호출할 수 있도록
    implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")
}

 

의존성 역할
spring-ai-starter-model-openai OpenAI Chat 모델을 Spring에서 쉽게 사용 (ChatClient, ChatModel 자동 설정)
spring-ai-openai OpenAI 임베딩/세부 기능 지원
spring-ai-rag Retriever, RAG 파이프라인, VectorStore 연동 유틸 제공
spring-ai-starter-vector-store-mariadb MariaDB를 VectorStore처럼 사용할 수 있게 해주는 스타터
spring-ai-starter-mcp-server-webmvc MCP 서버를 열어서 외부에서 Spring AI 기능을 호출할 수 있게 함

 

2-2. OpenAI 설정 (application.yml)

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      # 기본 Base URL (변경 없으면 주석 가능)
      base-url: https://api.openai.com

      chat:
        options:
          model: gpt-5.1          # 기본 Chat 모델
          temperature: 0.3        # Gpt5는 temperature 1고정.. 모델과 필요에 따라 수정

      embedding:
        options:
          model: text-embedding-3-small

3. AIConfig – 모델과 ChatClient 중앙 관리

우리 팀은 AIConfig 라는 설정 클래스를 하나 두고,
그 안에서 기본으로 사용할 ChatClient를 Bean으로 등록해서 사용했다.

 
@Configuration
public class AIConfig {

    @Bean
    @Primary
    public ChatClient openAiChatClient(OpenAiApi openAiApi) {

        OpenAiChatOptions options = OpenAiChatOptions.builder()
            .model("gpt-4.1-mini")
            .temperature(1.0)
            .build();

        OpenAiChatModel chatModel = OpenAiChatModel.builder()
            .openAiApi(openAiApi)
            .defaultOptions(options)
            .build();

        return ChatClient.builder(chatModel).build();
    }
}

포인트는:

  • 어디서든 ChatClient만 주입받아서 GPT 호출
    → @Service 에서 ChatClient 받아서 .prompt().system().user().call() 이런 식으로 사용
  • 모델 이름(gpt-4.1-mini), temperature 등은
    설정 클래스에서 한 번만 정의 → 변경 필요 시 이 파일만 수정

원하면 여기서 용도별로
예를 들어 reviewSummaryChatClient, recommendationChatClient 처럼
Bean 이름을 다르게 두고 모델/옵션을 분리하는 것도 가능하다.


4. system-prompt.yml – 프롬프트를 코드 밖(YAML)으로 분리

프롬프트는 Java 코드 안에 하드코딩하지 않고,
system-prompt.yml 에 정리해두고 @Value / @ConfigurationProperties 등으로 불러서 사용했다.

예시:

 
custom:
  ai:
    author-review-summary-prompt: |
      너는 후기 요약 전문가야.
      아래에는 특정 사용자가 작성한 게시글들에 대해 실제 이용자들이 남긴 후기 목록이 주어진다.
      이 후기들은 장비 자체의 상태뿐 아니라, 작성자(호스트)의 응대 태도와 거래 경험도 함께 반영된 내용이다.
      ...

이렇게 해두면 좋은 점:

  1. 프롬프트 수정이 코드 배포 없이 가능
    • YAML만 수정하면 톤/길이/구조 조정 가능
  2. 프롬프트 버전 관리
    • Git으로 yml 파일 diff를 보면서 “프롬프트 실험 로그”를 남길 수 있음
  3. 엔드포인트별로 프롬프트 역할 분리
    • review-summary, author-review-summary, post-recommendation, qa-with-context 등으로 목적별 관리

서비스 코드에서 쓰는 방식 예시:

 
@Service
@RequiredArgsConstructor
public class ReviewSummaryService {

    private final ChatClient chatClient;

    @Value("${custom.ai.review-summary-prompt}")
    private String reviewSummarySystemPrompt;

    public String summarizeReviews(String reviewsAsText) {
        return chatClient.prompt()
                .system(reviewSummarySystemPrompt)
                .user(reviewsAsText)
                .call()
                .content();
    }
}

5. PostVectorService – VectorStore + RAG를 감싼 도메인 서비스

벡터 관련 로직은 도메인에 붙여서 PostVectorService 라는 이름으로 분리해두었고,
Spring AI의 VectorStore 를 주입받아 사용했다.

(실제 코드는 생략/축약해서 개념만 정리)

 
@Service
@RequiredArgsConstructor
@Transactional
public class PostVectorService {

    private final VectorStore vectorStore;  // spring-ai-starter-vector-store-mariadb 가 생성해주는 Bean
    private final JdbcTemplate jdbcTemplate; // 필요 시 직접 쿼리용

    public void indexPost(PostEmbeddingDto dto) {
        // 1. 게시글 정보를 하나의 Document 로 구성
        Document doc = new Document(
            dto.content(), // 벡터로 변환할 텍스트
            Map.of(
                "postId", dto.id(),
                "title", dto.title(),
                "authorId", dto.authorId()
            )
        );

        // 2. VectorStore에 저장 → 내부적으로 Embedding + DB 저장까지 처리
        vectorStore.add(List.of(doc));
    }

    @Transactional(readOnly = true)
    public List<Document> searchSimilarPosts(String queryText, int topK) {

        SearchRequest request = SearchRequest.query(queryText)
            .withTopK(topK);

        // queryText 를 임베딩 → MariaDB VectorStore에서 유사 문서 검색
        return vectorStore.search(request);
    }
}

여기서 핵심은:

  • Embedding을 직접 생성/저장하지 않아도 된다
    • vectorStore.add() 를 호출하면 Spring AI가 내부적으로
      • 텍스트 → EmbeddingModel로 벡터 생성
      • MariaDB VectorStore에 저장
        까지 한 번에 처리해줌.
  • 검색할 때도 SearchRequest.query("텍스트") 정도만 넘겨주면
    • 같은 EmbeddingModel을 사용해서 쿼리 벡터를 만들고
    • DB에서 유사 벡터를 찾아서 Document로 돌려줌.

도메인 서비스에서는 “벡터 DB를 쓴다”는 디테일을 모르게 하고,
“게시글을 벡터 인덱싱 한다 / 비슷한 게시글을 찾는다”라는 업무 로직에만 집중할 수 있게 한 구조다.


6. RAG 파이프라인 설계 – 전체 흐름

이번 프로젝트에서 사용한 흐름을 기준으로, Spring AI + MariaDB RAG 파이프라인을 정리하면:

  1. 오프라인 단계 (사전 작업)
    • 도메인 데이터(게시글/여행지/후기 등) → Chunking
    • 각 Chunk 텍스트를 EmbeddingModel로 벡터 변환
    • MariaDB rag_document 테이블에 저장
  2. 온라인 단계 (사용자 질문 처리)
    • 사용자의 질문 → EmbeddingModel로 질문 임베딩 생성
    • 질문 벡터와 DB에 저장된 문서 벡터들의 거리(L2/Cosine) 계산
    • 가장 유사도가 높은 Top-N 문서를 RAG 컨텍스트로 선택
    • 선택된 문서를 Prompt에 넣고 ChatClient로 LLM 호출
    • LLM 응답을 사용자에게 반환

6-1. 1단계 – 도메인 데이터 → Chunk & 저장

간단한 “데이터 적재(batch)” 서비스 예시:

 
@Service
@RequiredArgsConstructor
public class RagIngestionService {

    private final EmbeddingService embeddingService;
    private final RagDocumentRepository ragDocumentRepository;
    private final ObjectMapper objectMapper;

    @Transactional
    public void ingestPlace(Long placeId, String placeContent) {
        // 1. Chunking (여기서는 예시로 전체를 그대로 사용)
        List<String> chunks = simpleChunk(placeContent);

        for (String chunk : chunks) {
            float[] embedding = embeddingService.embedText(chunk);
            String embeddingJson = toJson(embedding);

            RagDocument document = RagDocument.builder()
                    .refType("PLACE")
                    .refId(placeId)
                    .content(chunk)
                    .embeddingJson(embeddingJson)
                    .build();

            ragDocumentRepository.save(document);
        }
    }

    private List<String> simpleChunk(String text) {
        // 실제로는 토큰 기준 / 문단 기준으로 나누는 로직 권장
        return List.of(text);
    }

    private String toJson(float[] embedding) {
        try {
            return objectMapper.writeValueAsString(embedding);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize embedding", e);
        }
    }
}

실제로는 Token 길이 기준으로 200~500 토큰 단위 Chunking을 추천.

6-2. 2단계 – 질문 → 벡터 검색 → LLM 호출 (RAG 핵심)

6-2-1. 벡터 거리 계산 유틸

 
public class VectorUtils {

    // L2 거리
    public static double l2(float[] v1, float[] v2) {
        double sum = 0.0;
        int len = Math.min(v1.length, v2.length);
        for (int i = 0; i < len; i++) {
            double d = v1[i] - v2[i];
            sum += d * d;
        }
        return Math.sqrt(sum);
    }

    // Cosine Similarity (원하면 사용)
    public static double cosine(float[] v1, float[] v2) {
        double dot = 0, norm1 = 0, norm2 = 0;
        int len = Math.min(v1.length, v2.length);
        for (int i = 0; i < len; i++) {
            dot += v1[i] * v2[i];
            norm1 += v1[i] * v1[i];
            norm2 += v2[i] * v2[i];
        }
        return dot / (Math.sqrt(norm1) * Math.sqrt(norm2) + 1e-8);
    }
}

6-2-2. RAG 서비스 구현 예시

@Service
@RequiredArgsConstructor
public class RagService {

    private final EmbeddingService embeddingService;
    private final RagDocumentRepository ragDocumentRepository;
    private final ChatClient chatClient;
    private final ObjectMapper objectMapper;

    @Transactional(readOnly = true)
    public String askWithRag(String question) {
        // 1. 질문 임베딩 생성
        float[] questionEmbedding = embeddingService.embedText(question);

        // 2. 후보 문서 조회 (여기선 전체 또는 refType 기준 필터)
        List<RagDocument> candidates = ragDocumentRepository.findByRefType("PLACE");

        // 3. 각 문서와 거리 계산 후 상위 N개 선택
        int topN = 5;
        List<RagDocument> topDocs = candidates.stream()
                .sorted(Comparator.comparingDouble(doc -> distance(questionEmbedding, doc)))
                .limit(topN)
                .toList();

        // 4. context 문자열 구성
        String context = buildContext(topDocs);

        // 5. ChatClient로 RAG 프롬프트 호출
        return chatClient
                .prompt()
                .system("""
                        너는 여행 추천 도우미야.
                        아래에 제공되는 컨텍스트 내용만을 기반으로 최대한 정확하게 대답해.
                        컨텍스트에 없는 내용은 모른다고 말해줘.
                        """)
                .user("질문: " + question + "\n\n컨텍스트:\n" + context)
                .call()
                .content();
    }

    private double distance(float[] questionEmbedding, RagDocument doc) {
        float[] docEmbedding = fromJson(doc.getEmbeddingJson());
        return VectorUtils.l2(questionEmbedding, docEmbedding);
    }

    private float[] fromJson(String json) {
        try {
            List<Float> list = objectMapper.readValue(json, new TypeReference<List<Float>>() {});
            float[] arr = new float[list.size()];
            for (int i = 0; i < list.size(); i++) arr[i] = list.get(i);
            return arr;
        } catch (Exception e) {
            throw new RuntimeException("Failed to parse embedding json", e);
        }
    }

    private String buildContext(List<RagDocument> docs) {
        StringBuilder sb = new StringBuilder();
        for (RagDocument doc : docs) {
            sb.append("- [")
              .append(doc.getRefType())
              .append("#")
              .append(doc.getRefId())
              .append("]\n")
              .append(doc.getContent())
              .append("\n\n");
        }
        return sb.toString();
    }
}

이렇게 하면

  • 질문 → 임베딩
  • DB에서 유사한 문서 Top-N 추출
  • 컨텍스트 + 질문을 하나의 프롬프트로 합치기
  • Spring AI ChatClient로 OpenAI 호출

까지 전 과정을 Spring AI + MariaDB로 구현할 수 있다.


7. 전체 흐름 요약

지금까지 내용을 RAG 관점에서 한 번에 정리하면:

  1. 의존성 구성
    • spring-ai-openai, spring-ai-rag, spring-ai-starter-vector-store-mariadb 등으로 기반 세팅
  2. AIConfig
    • ChatClient 를 Bean으로 등록, 기본 모델(gpt-4.1-mini)과 옵션(temperature 등)을 중앙 관리
  3. 프롬프트 관리 (system-prompt.yml)
    • 후기 요약, 작성자 평판 요약, 게시글 추천, Q&A 등 프롬프트를 용도별로 YAML에 정의
    • 서비스 코드에서는 @Value 로 주입해서 사용
  4. 벡터 인덱싱 (PostVectorService.indexPost)
    • 게시글/여행지/후기 → Document화 → vectorStore.add() 로 MariaDB VectorStore에 저장
  5. 검색 + RAG 응답 (예: RecommendationService)
    • 사용자 질문 → vectorStore.search() 로 관련 게시글 N개 검색
    • 검색 결과(Document들)를 프롬프트에 집어넣어 컨텍스트로 사용
    • ChatClient 로 최종 답변 생성

 

참고자료:

https://spring.io/projects/spring-ai

 

Spring AI

Spring AI is an application framework for AI engineering. Its goal is to apply to the AI domain Spring ecosystem design principles such as portability and modular design and promote using POJOs as the building blocks of an application to the AI domain. At

spring.io

 

 

LIST

'BE > Spring' 카테고리의 다른 글

[JPA] OSIV ( Open Session In View )  (0) 2025.11.25
[Spring] JOOQ ( + QueryDSL vs JOOQ )  (0) 2025.11.21
[Spring] Spring Security + OAuth2 로그인  (0) 2025.10.15
[Spring] JPA + QueryDSL 정리  (0) 2025.10.02
[Spring] MyBatis 구조 정리  (0) 2025.08.29