Spring AI로 질문에 답하는 Kotlin 앱 만들기 — 튜토리얼(Create a Kotlin app that answers questions with Spring AI)

Spring AI로 질문에 답하는 Kotlin 앱 만들기 — 튜토리얼(Create a Kotlin app that answers questions with Spring AI)

이 튜토리얼에서는 Spring AI를 통해 LLM에 연결하고, 문서를 벡터 데이터베이스에 저장한 뒤, 그 문서의 컨텍스트를 이용해 질문에 답하는 Kotlin 앱을 만드는 방법을 배워요.

이 튜토리얼에서 사용할 도구는 다음과 같아요:

  • Spring Boot: 웹 애플리케이션을 구성하고 실행하는 기반.
  • Spring AI: LLM과 상호작용하고 컨텍스트 기반 검색을 수행.
  • IntelliJ IDEA: 프로젝트를 생성하고 애플리케이션 로직을 구현.
  • Qdrant: 유사도 검색용 벡터 데이터베이스.
  • Docker: Qdrant를 로컬에서 실행.
  • OpenAI: LLM 제공자.

출처: Create a Kotlin app that answers questions with Spring AI — tutorial

본문

시작하기 전에(Before you start)

  1. 최신 버전의 IntelliJ IDEA를 다운로드해 설치하고 Ultimate 구독을 사용해요. IntelliJ IDEA를 Ultimate 구독 없이 쓰거나 다른 IDE를 쓴다면 웹 기반 프로젝트 생성기로 Spring Boot 프로젝트를 만들 수 있어요.
  2. OpenAI 플랫폼에서 API에 접근할 OpenAI API 키를 만들어요.
  3. Qdrant 벡터 데이터베이스를 로컬에서 실행하기 위해 Docker를 설치해요.
  4. Docker를 설치한 뒤 터미널을 열고 다음 명령을 실행해 컨테이너를 시작해요:
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant

프로젝트 만들기(Create the project)

Spring Boot 웹 기반 프로젝트 생성기를 대안으로 사용해 프로젝트를 만들 수도 있어요.

Ultimate 구독으로 IntelliJ IDEA에서 새 Spring Boot 프로젝트를 만들어요:

  1. IntelliJ IDEA에서 File | New | Project를 선택해요.

  2. 왼쪽 패널에서 New Project | Spring Boot를 선택해요.

  3. New Project 창에서 다음 필드와 옵션을 지정해요:

    • Name: springAIDemo
    • Language: Kotlin
    • Type: Gradle - Kotlin 이 옵션은 빌드 시스템과 DSL을 지정해요.
    • Package name: org.example.springaidemo
    • JDK: Java JDK 이 튜토리얼은 Oracle OpenJDK 버전 21.0.1을 사용해요. JDK가 설치되어 있지 않다면 드롭다운 목록에서 다운로드할 수 있어요.
    • Java: 17 Java 17이 설치되어 있지 않다면 JDK 드롭다운 목록에서 다운로드할 수 있어요.

    [IMAGE: Create Spring Boot project]

  4. 모든 필드를 지정했는지 확인하고 Next를 클릭해요.

  5. Spring Boot 필드에서 최신 안정 버전의 Spring Boot를 선택해요.

  6. 이 튜토리얼에 필요한 다음 의존성을 선택해요:

    • Web | Spring Web
    • AI | OpenAI
    • SQL | Qdrant Vector Database

    [IMAGE: Set up Spring Boot project]

  7. Create를 클릭해 프로젝트를 생성하고 설정해요. IDE가 새 프로젝트를 생성하고 열 거예요. 프로젝트 의존성을 다운로드하고 가져오는 데 시간이 걸릴 수 있어요.

이후 Project 뷰에서 다음과 같은 구조를 볼 수 있어요:

[IMAGE: Spring Boot project view]

생성된 Gradle 프로젝트는 Maven의 표준 디렉터리 구조에 해당해요:

  • main/kotlin 폴더 아래에 애플리케이션에 속하는 패키지와 클래스가 있어요.
  • 애플리케이션의 진입점은 SpringAiDemoApplication.kt 파일의 main() 메서드예요.

프로젝트 구성 업데이트하기(Update the project configuration)

  1. build.gradle.kts Gradle 빌드 파일을 다음과 같이 업데이트해요:
plugins {
    kotlin("jvm") version "2.4.20"
    kotlin("plugin.spring") version "2.4.20"
    // Rest of the plugins
}
  1. springAiVersion2.0.0으로 설정해요:
extra["springAiVersion"] = "2.0.0"
  1. Sync Gradle Changes 버튼을 클릭해 Gradle 파일을 동기화해요.
  2. src/main/resources/application.properties 파일을 다음과 같이 업데이트해요:
# OpenAI
spring.ai.openai.api-key=YOUR_OPENAI_API_KEY
spring.ai.openai.chat.model=gpt-4o-mini
spring.ai.openai.embedding.model=text-embedding-ada-002
# Qdrant
spring.ai.vectorstore.qdrant.host=localhost
spring.ai.vectorstore.qdrant.port=6334
spring.ai.vectorstore.qdrant.collection-name=kotlinDocs
spring.ai.vectorstore.qdrant.initialize-schema=true

OpenAI API 키를 spring.ai.openai.api-key 속성에 설정해요.

  1. SpringAiDemoApplication.kt 파일을 실행해 Spring Boot 애플리케이션을 시작해요. 실행되면 브라우저에서 Qdrant collections 페이지를 열어 결과를 확인해요:

[IMAGE: Qdrant collections]

문서를 로드하고 검색하는 컨트롤러 만들기(Create a controller to load and search documents)

문서를 검색하고 Qdrant 컬렉션에 저장하는 Spring @RestController를 만들어요:

  1. src/main/kotlin/org/example/springaidemo 디렉터리에 KotlinSTDController.kt라는 새 파일을 만들고 다음 코드를 추가해요:
package org.example.springaidemo
// Imports the required Spring and utility classes
import org.slf4j.LoggerFactory
import org.springframework.ai.document.Document
import org.springframework.ai.vectorstore.SearchRequest
import org.springframework.ai.vectorstore.VectorStore
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

@RestController
@RequestMapping("/kotlin")
class KotlinSTDController(
    private val restTemplate: RestTemplate,
    private val vectorStore: VectorStore,
) {
    private val logger = LoggerFactory.getLogger(this::class.java)

    @OptIn(ExperimentalUuidApi::class)
    @PostMapping("/load-docs")
    fun load() {
        // Loads a list of documents from the Kotlin documentation
        val kotlinStdTopics = listOf(
            "collections-overview", "constructing-collections", "iterators", "ranges", "sequences",
            "collection-operations", "collection-transformations", "collection-filtering", "collection-plus-minus",
            "collection-grouping", "collection-parts", "collection-elements", "collection-ordering",
            "collection-aggregate", "collection-write", "list-operations", "set-operations",
            "map-operations", "read-standard-input", "opt-in-requirements", "scope-functions", "time-measurement",
        )
        // Base URL for the documents
        val url = "https://raw.githubusercontent.com/JetBrains/kotlin-web-site/refs/heads/master/docs/topics/"
        // Retrieves each document from the URL and adds it to the vector store
        kotlinStdTopics.forEach { topic ->
            val data = restTemplate.getForObject("$url$topic.md", String::class.java)
            data?.let { it ->
                val doc = Document.builder()
                    // Builds a document with a random UUID
                    .id(Uuid.random().toString())
                    .text(it)
                    .metadata("topic", topic)
                    .build()
                vectorStore.add(listOf(doc))
                logger.info("Document $topic loaded.")
            } ?: logger.warn("Failed to load document for topic: $topic")
        }
    }

    @GetMapping("docs")
    fun query(
        @RequestParam query: String = "operations, filtering, and transformations",
        @RequestParam topK: Int = 2
    ): List<Document>? {
        val searchRequest = SearchRequest.builder()
            .query(query)
            .topK(topK)
            .build()
        val results = vectorStore.similaritySearch(searchRequest)
        logger.info("Found ${results?.size ?: 0} documents for query: '$query'")
        return results
    }
}
  1. SpringAiDemoApplication.kt 파일을 업데이트해 RestTemplate 빈(bean)을 선언해요:
package org.example.springaidemo
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.web.client.RestTemplate

@SpringBootApplication
class SpringAiDemoApplication {
    @Bean
    fun restTemplate(): RestTemplate = RestTemplate()
}

fun main(args: Array<String>) {
    runApplication<SpringAiDemoApplication>(*args)
}
  1. 애플리케이션을 실행해요.
  2. 터미널에서 /kotlin/load-docs 엔드포인트로 POST 요청을 보내 문서를 로드해요:
curl -X POST http://localhost:8080/kotlin/load-docs
  1. 문서가 로드되면 GET 요청으로 검색할 수 있어요:
curl -X GET http://localhost:8080/kotlin/docs

[IMAGE: GET request results]

결과는 Qdrant collections 페이지에서도 확인할 수 있어요.

AI 채팅 엔드포인트 구현하기(Implement an AI chat endpoint)

문서가 로드되면 마지막 단계로, Spring AI의 RAG(Retrieval-Augmented Generation, 검색 증강 생성) 지원을 통해 Qdrant의 문서를 사용해 질문에 답하는 엔드포인트를 추가해요:

  1. KotlinSTDController.kt 파일을 열고 다음 클래스들을 import해요:
import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor
import org.springframework.ai.chat.prompt.Prompt
import org.springframework.ai.chat.prompt.PromptTemplate
import org.springframework.web.bind.annotation.RequestBody
  1. ChatRequest 데이터 클래스를 정의해요:
// Represents the request payload for chat queries
data class ChatRequest(val query: String, val topK: Int = 3)
  1. 컨트롤러의 생성자 파라미터에 ChatClient.Builder를 추가해요:
class KotlinSTDController(
    // Provides the builder for creating the ChatClient
    private val chatClientBuilder: ChatClient.Builder,
    private val restTemplate: RestTemplate,
    private val vectorStore: VectorStore,
)
  1. 컨트롤러 클래스 안에 ChatClient 인스턴스를 만들어요:
// Builds the chat client with a simple logging advisor
private val chatClient = chatClientBuilder.defaultAdvisors(SimpleLoggerAdvisor()).build()
  1. KotlinSTDController.kt 파일 맨 아래에 다음 로직을 가진 새 chatAsk() 엔드포인트를 추가해요:
@PostMapping("/chat/ask")
fun chatAsk(@RequestBody request: ChatRequest): String? {
    // Defines the prompt template with placeholders
    val promptTemplate = PromptTemplate(
        """
        {query}.
        Please provide a concise answer based on the "Kotlin standard library" documentation.
    """.trimIndent()
    )
    // Creates the prompt by substituting placeholders with actual values
    val prompt: Prompt =
        promptTemplate.create(mapOf("query" to request.query))
    // Configures the retrieval advisor to augment the query with relevant documents
    val retrievalAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
        .searchRequest(
            SearchRequest.builder()
                .similarityThreshold(0.7)
                .topK(request.topK)
                .build()
        )
        .promptTemplate(promptTemplate)
        .build()
    // Sends the prompt to the LLM with the retrieval advisor and retrieves the generated content
    val response = chatClient.prompt(prompt)
        .advisors(retrievalAdvisor)
        .call()
        .content()
    logger.info("Chat response generated for query: '${request.query}'")
    return response
}
  1. 애플리케이션을 실행해요.
  2. 터미널에서 새 엔드포인트로 POST 요청을 보내 결과를 확인해요:
curl -X POST "http://localhost:8080/kotlin/chat/ask" \
     -H "Content-Type: application/json" \
     -d '{"query": "What are the performance implications of using lazy sequences in Kotlin for large datasets?", "topK": 3}'

[IMAGE: OpenAI answer to chat request]

축하해요! 이제 OpenAI에 연결하고 Qdrant에 저장된 문서에서 가져온 컨텍스트로 질문에 답하는 Kotlin 앱이 완성됐어요. 다양한 질문으로 실험하거나 다른 문서를 가져와 더 많은 가능성을 탐구해 보세요.

완성된 프로젝트는 Spring AI demo GitHub 저장소에서 확인할 수 있어요.

다음 단계(What's next)

더 알아보기