From 0832a006c3289e41b42a2c131e1ca8c92d3c1571 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Mon, 28 Apr 2025 03:01:12 +0900 Subject: [PATCH 01/18] =?UTF-8?q?build:=20=EA=B4=80=EB=A0=A8=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 4576e4c..4c6ab04 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,9 +22,25 @@ extra["springAiVersion"] = "1.0.0-M6" dependencies { implementation("org.springframework.boot:spring-boot-starter-web") + + // Kotlin 지원 implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + + // 코루틴 의존성 + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + + // Spring AI 의존성 implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter") + + // Swagger/OpenAPI 의존성 + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") + + // 로깅 라이브러리 + implementation("io.github.oshai:kotlin-logging:6.0.3") + + // 테스트 의존성 testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") From df8b8aefaa72261872f97033150b2d5a6c2195c9 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Mon, 28 Apr 2025 03:03:40 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20OpenAI=20API=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring_ai_tutorial/config/OpenAiConfig.kt | 29 +++++++++++++++++++ src/main/resources/application.properties | 6 ++++ 2 files changed, 35 insertions(+) create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/config/OpenAiConfig.kt diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/config/OpenAiConfig.kt b/src/main/kotlin/com/example/spring_ai_tutorial/config/OpenAiConfig.kt new file mode 100644 index 0000000..8ad303d --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/config/OpenAiConfig.kt @@ -0,0 +1,29 @@ +package com.example.spring_ai_tutorial.config + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.ai.openai.api.OpenAiApi +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * OpenAI API 설정 + */ +@Configuration +class OpenAiConfig { + private val logger = KotlinLogging.logger {} + + @Value("\${spring.ai.openai.api-key}") + private lateinit var apiKey: String + + /** + * OpenAI API 클라이언트 빈 등록 + */ + @Bean + fun openAiApi(): OpenAiApi { + logger.debug { "OpenAI API 클라이언트 초기화" } + return OpenAiApi.builder() + .apiKey(apiKey) + .build() + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3b15fda..20e57af 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,7 @@ spring.application.name=spring-ai-tutorial + +# Server Configuration +server.port=8080 + +# OpenAI API Configuration +spring.ai.openai.api-key=${OPENAI_API_KEY} From d3f410575118e47c337443d7fbec53b01a9c97dc Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Mon, 28 Apr 2025 03:05:31 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20OpenAI=20=EC=B1=97=EB=B4=87=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/OpenApiConfig.kt | 21 ++++ .../controller/ChatController.kt | 116 ++++++++++++++++++ .../spring_ai_tutorial/service/ChatService.kt | 48 ++++++++ src/main/resources/application.properties | 7 ++ 4 files changed, 192 insertions(+) create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/config/OpenApiConfig.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/config/OpenApiConfig.kt b/src/main/kotlin/com/example/spring_ai_tutorial/config/OpenApiConfig.kt new file mode 100644 index 0000000..400db70 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/config/OpenApiConfig.kt @@ -0,0 +1,21 @@ +package com.example.spring_ai_tutorial.config + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig { + + @Bean + fun springOpenAPI(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("Spring AI Tutorial API") + .version("1.0") + .description("Spring AI를 활용한 챗봇 API") + ) + } +} diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt b/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt new file mode 100644 index 0000000..8c14733 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt @@ -0,0 +1,116 @@ +package com.example.spring_ai_tutorial.controller + +import com.example.spring_ai_tutorial.service.ChatService +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerResponse +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * Chat API 컨트롤러 + * + * LLM API를 통해 채팅 기능을 제공합니다. + */ +@RestController +@RequestMapping("/api/v1/chat") +@Tag(name = "Chat API", description = "OpenAI API를 통한 채팅 기능") +class ChatController( + private val chatService: ChatService +) { + private val logger = KotlinLogging.logger {} + + /** + * 사용자의 메시지를 받아 OpenAI API로 응답 생성 + */ + @Operation( + summary = "LLM 채팅 메시지 전송", + description = "사용자의 메시지를 받아 OpenAI API를 통해 응답을 생성합니다." + ) + @SwaggerResponse( + responseCode = "200", + description = "LLM 응답 성공", + content = [Content(schema = Schema(implementation = ApiResponse::class))] + ) + @SwaggerResponse(responseCode = "400", description = "잘못된 요청") + @SwaggerResponse(responseCode = "500", description = "서버 오류") + @PostMapping("/query") + suspend fun sendMessage( + @Parameter(description = "채팅 요청 객체", required = true) + @RequestBody request: ChatRequest + ): ResponseEntity>> { + logger.info { "Chat API 요청 받음: model=${request.model}" } + + // 유효성 검사 + if (request.query.isBlank()) { + logger.warn { "빈 질의가 요청됨" } + return ResponseEntity.badRequest().body( + ApiResponse(success = false, error = "질의가 비어있습니다.") + ) + } + + return try { + // 시스템 프롬프트 지정 + val systemMessage = "You are a helpful AI assistant." + + // AI 응답 생성 + val response = chatService.openAiChat( + userInput = request.query, + systemMessage = systemMessage, + model = request.model + ) + logger.debug { "LLM 응답 생성: $response" } + + response?.let { chatResponse -> + ResponseEntity.ok( + ApiResponse( + success = true, + data = mapOf("answer" to chatResponse.result.output.text) + ) + ) + } ?: run { + logger.error { "LLM 응답 생성 실패" } + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponse( + success = false, + error = "LLM 응답 생성 중 오류 발생" + ) + ) + } + } catch (e: Exception) { + logger.error(e) { "Chat API 처리 중 오류 발생" } + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponse( + success = false, + error = e.message ?: "알 수 없는 오류 발생" + ) + ) + } + } +} + +@Schema(description = "채팅 요청 데이터 모델") +data class ChatRequest( + @Schema(description = "사용자 질문", example = "안녕하세요") + val query: String, + + @Schema(description = "사용할 LLM 모델", example = "gpt-3.5-turbo", defaultValue = "gpt-3.5-turbo") + val model: String = "gpt-3.5-turbo" +) + +@Schema(description = "API 응답 포맷") +data class ApiResponse( + @Schema(description = "요청 처리 성공 여부") + val success: Boolean, + + @Schema(description = "성공 응답 데이터") + val data: T? = null, + + @Schema(description = "실패 오류 메시지") + val error: String? = null +) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt new file mode 100644 index 0000000..8470353 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt @@ -0,0 +1,48 @@ +package com.example.spring_ai_tutorial.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.springframework.ai.chat.model.ChatResponse +import org.springframework.ai.openai.api.OpenAiApi +import org.springframework.stereotype.Service + +/** + * OpenAI API를 사용하여 질의응답을 수행하는 서비스 + */ +@Service +class ChatService( + private val openAiApi: OpenAiApi +) { + private val logger = KotlinLogging.logger {} + + /** + * OpenAI 챗 API를 이용하여 응답을 생성합니다. + * + * @param userInput 사용자 입력 메시지 + * @param systemMessage 시스템 프롬프트 + * @param model 사용할 LLM 모델명 + * @return 챗 응답 객체, 오류 시 null + */ + suspend fun openAiChat( + userInput: String, + systemMessage: String, + model: String = "gpt-3.5-turbo" + ): ChatResponse? = withContext(Dispatchers.IO) { + logger.debug { "OpenAI 챗 호출 시작 - 모델: $model" } + try { + // 메시지 구성 + + // 챗 옵션 설정 + + // 프롬프트 생성 + + // 챗 모델 생성 및 호출 + + return@withContext TODO("응답 생성 로직을 작성하세요") + } catch (e: Exception) { + logger.error(e) { "OpenAI 챗 호출 중 오류 발생: ${e.message}" } + return@withContext null + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 20e57af..29c8587 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,3 +5,10 @@ server.port=8080 # OpenAI API Configuration spring.ai.openai.api-key=${OPENAI_API_KEY} + +# File Upload Settings +spring.servlet.multipart.max-file-size=20MB +spring.servlet.multipart.max-request-size=20MB + +# Swagger/OpenAPI Configuration +springdoc.api-docs.path=/api-docs From 749e4d42ef5cc875922027af90dd1e8127501fc6 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Tue, 6 May 2025 21:45:08 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20openAiChat=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring_ai_tutorial/service/ChatService.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt index 8470353..6d2faf1 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt @@ -3,7 +3,12 @@ package com.example.spring_ai_tutorial.service import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.springframework.ai.chat.messages.SystemMessage +import org.springframework.ai.chat.messages.UserMessage import org.springframework.ai.chat.model.ChatResponse +import org.springframework.ai.chat.prompt.ChatOptions +import org.springframework.ai.chat.prompt.Prompt +import org.springframework.ai.openai.OpenAiChatModel import org.springframework.ai.openai.api.OpenAiApi import org.springframework.stereotype.Service @@ -32,14 +37,25 @@ class ChatService( logger.debug { "OpenAI 챗 호출 시작 - 모델: $model" } try { // 메시지 구성 + val messages = listOf( + SystemMessage(systemMessage), + UserMessage(userInput) + ) // 챗 옵션 설정 + val chatOptions = ChatOptions.builder() + .model(model) + .build() // 프롬프트 생성 + val prompt = Prompt(messages, chatOptions) // 챗 모델 생성 및 호출 + val chatModel = OpenAiChatModel.builder() + .openAiApi(openAiApi) + .build() - return@withContext TODO("응답 생성 로직을 작성하세요") + return@withContext chatModel.call(prompt) } catch (e: Exception) { logger.error(e) { "OpenAI 챗 호출 중 오류 발생: ${e.message}" } return@withContext null From b97e921fcbad51562c6df42f02eaa29ae22e38bb Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 15:57:54 +0900 Subject: [PATCH 05/18] =?UTF-8?q?=EC=9E=84=EB=B2=A0=EB=94=A9=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 29c8587..ccaf497 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,6 +5,7 @@ server.port=8080 # OpenAI API Configuration spring.ai.openai.api-key=${OPENAI_API_KEY} +spring.ai.openai.embedding.options.model=text-embedding-3-small # File Upload Settings spring.servlet.multipart.max-file-size=20MB From c37945a9491b40181bfecf5615b654c1b287f3df Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 15:58:56 +0900 Subject: [PATCH 06/18] =?UTF-8?q?DTO=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring_ai_tutorial/domain/dto/ChatDto.kt | 19 +++++++ .../domain/dto/CommonDto.kt | 20 +++++++ .../domain/dto/DocumentDto.kt | 55 +++++++++++++++++++ .../spring_ai_tutorial/domain/dto/RagDto.kt | 49 +++++++++++++++++ .../exception/DocumentProcessingException.kt | 12 ++++ 5 files changed, 155 insertions(+) create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/ChatDto.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/CommonDto.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/RagDto.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/exception/DocumentProcessingException.kt diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/ChatDto.kt b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/ChatDto.kt new file mode 100644 index 0000000..388abe7 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/ChatDto.kt @@ -0,0 +1,19 @@ +package com.example.spring_ai_tutorial.domain.dto + +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 채팅 관련 DTO 클래스들 + */ + +/** + * 채팅 요청 데이터 모델 + */ +@Schema(description = "채팅 요청 데이터 모델") +data class ChatRequestDto( + @Schema(description = "사용자 질문", example = "안녕하세요") + val query: String, + + @Schema(description = "사용할 LLM 모델", example = "gpt-3.5-turbo", defaultValue = "gpt-3.5-turbo") + val model: String = "gpt-3.5-turbo" +) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/CommonDto.kt b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/CommonDto.kt new file mode 100644 index 0000000..881ac4b --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/CommonDto.kt @@ -0,0 +1,20 @@ +package com.example.spring_ai_tutorial.domain.dto + +import io.swagger.v3.oas.annotations.media.Schema + +/** + * API 표준 응답 포맷 + * + * 모든 API 응답에 사용되는 공통 응답 포맷입니다. + */ +@Schema(description = "API 표준 응답 포맷") +data class ApiResponseDto( + @Schema(description = "요청 처리 성공 여부") + val success: Boolean, + + @Schema(description = "응답 데이터 (성공 시)") + val data: T? = null, + + @Schema(description = "오류 메시지 (실패 시)") + val error: String? = null +) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt new file mode 100644 index 0000000..da3ca69 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt @@ -0,0 +1,55 @@ +package com.example.spring_ai_tutorial.domain.dto + +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 문서 관련 DTO 클래스들 + */ + +/** + * 문서 검색 결과 + */ +@Schema(description = "문서 검색 결과") +data class DocumentSearchResultDto( + @Schema(description = "문서 ID") + val id: String, + + @Schema(description = "문서 내용") + val content: String, + + @Schema(description = "문서 메타데이터") + val metadata: Map, + + @Schema(description = "관련도 점수") + val score: Double +) + +/** + * 문서 응답 데이터 + */ +@Schema(description = "문서 응답 데이터") +data class DocumentResponseDto( + @Schema(description = "문서 ID") + val id: String, + + @Schema(description = "관련도 점수") + val score: Double, + + @Schema(description = "문서 내용 (일부)") + val content: String, + + @Schema(description = "문서 메타데이터") + val metadata: Map +) + +/** + * DocumentSearchResultDto의 확장 함수로 DocumentResponseDto 변환 기능 + */ +fun DocumentSearchResultDto.toDocumentResponseDto(): DocumentResponseDto { + return DocumentResponseDto( + id = this.id, + score = this.score, + content = this.content.take(100) + if (this.content.length > 100) "..." else "", + metadata = this.metadata + ) +} diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/RagDto.kt b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/RagDto.kt new file mode 100644 index 0000000..4cd6a5f --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/RagDto.kt @@ -0,0 +1,49 @@ +package com.example.spring_ai_tutorial.domain.dto + +import io.swagger.v3.oas.annotations.media.Schema + +/** + * RAG 관련 DTO 클래스들 + */ + +/** + * 문서 업로드 결과 + */ +@Schema(description = "문서 업로드 결과") +data class DocumentUploadResultDto( + @Schema(description = "생성된 문서 ID") + val documentId: String, + + @Schema(description = "결과 메시지") + val message: String +) + +/** + * 질의 요청 데이터 모델 + */ +@Schema(description = "질의 요청 데이터 모델") +data class QueryRequestDto( + @Schema(description = "사용자 질문", example = "인공지능이란 무엇인가요?") + val query: String, + + @Schema(description = "최대 검색 결과 수", example = "3", defaultValue = "3") + val maxResults: Int = 3, + + @Schema(description = "사용할 LLM 모델", example = "gpt-3.5-turbo", defaultValue = "gpt-3.5-turbo") + val model: String = "gpt-3.5-turbo" +) + +/** + * 질의 응답 데이터 + */ +@Schema(description = "질의 응답 데이터") +data class QueryResponseDto( + @Schema(description = "원본 질의") + val query: String, + + @Schema(description = "생성된 답변") + val answer: String, + + @Schema(description = "관련 문서 목록") + val relevantDocuments: List +) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/exception/DocumentProcessingException.kt b/src/main/kotlin/com/example/spring_ai_tutorial/exception/DocumentProcessingException.kt new file mode 100644 index 0000000..8f5e9c9 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/exception/DocumentProcessingException.kt @@ -0,0 +1,12 @@ +package com.example.spring_ai_tutorial.exception + +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException + +/** + * 문서 처리 관련 커스텀 예외 + * + * 문서 처리 도중 발생하는 다양한 오류를 처리하기 위한 공통 예외 클래스입니다. + */ +class DocumentProcessingException(message: String, cause: Throwable? = null) : + ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message, cause) From a6fc56f3a0658eea705174b7569a5c81420587d4 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 16:00:32 +0900 Subject: [PATCH 07/18] =?UTF-8?q?PDF=20=EC=B2=98=EB=A6=AC=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 4c6ab04..394b401 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,9 @@ dependencies { // Spring AI 의존성 implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter") + + // PDF 처리 라이브러리 + implementation("org.apache.pdfbox:pdfbox:2.0.27") // Swagger/OpenAPI 의존성 implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") From c7bf51001710f0b7d6e1d45d29b70d059b067895 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 16:02:30 +0900 Subject: [PATCH 08/18] =?UTF-8?q?refactor:=20chat=20controller=EC=97=90=20?= =?UTF-8?q?DTO=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatController.kt | 37 +++++-------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt b/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt index 8c14733..576b4ad 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt @@ -1,5 +1,7 @@ package com.example.spring_ai_tutorial.controller +import com.example.spring_ai_tutorial.domain.dto.ApiResponseDto +import com.example.spring_ai_tutorial.domain.dto.ChatRequestDto import com.example.spring_ai_tutorial.service.ChatService import io.github.oshai.kotlinlogging.KotlinLogging import io.swagger.v3.oas.annotations.Operation @@ -35,22 +37,22 @@ class ChatController( @SwaggerResponse( responseCode = "200", description = "LLM 응답 성공", - content = [Content(schema = Schema(implementation = ApiResponse::class))] + content = [Content(schema = Schema(implementation = ApiResponseDto::class))] ) @SwaggerResponse(responseCode = "400", description = "잘못된 요청") @SwaggerResponse(responseCode = "500", description = "서버 오류") @PostMapping("/query") suspend fun sendMessage( @Parameter(description = "채팅 요청 객체", required = true) - @RequestBody request: ChatRequest - ): ResponseEntity>> { + @RequestBody request: ChatRequestDto + ): ResponseEntity>> { logger.info { "Chat API 요청 받음: model=${request.model}" } // 유효성 검사 if (request.query.isBlank()) { logger.warn { "빈 질의가 요청됨" } return ResponseEntity.badRequest().body( - ApiResponse(success = false, error = "질의가 비어있습니다.") + ApiResponseDto(success = false, error = "질의가 비어있습니다.") ) } @@ -68,7 +70,7 @@ class ChatController( response?.let { chatResponse -> ResponseEntity.ok( - ApiResponse( + ApiResponseDto( success = true, data = mapOf("answer" to chatResponse.result.output.text) ) @@ -76,7 +78,7 @@ class ChatController( } ?: run { logger.error { "LLM 응답 생성 실패" } ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - ApiResponse( + ApiResponseDto( success = false, error = "LLM 응답 생성 중 오류 발생" ) @@ -85,7 +87,7 @@ class ChatController( } catch (e: Exception) { logger.error(e) { "Chat API 처리 중 오류 발생" } ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - ApiResponse( + ApiResponseDto( success = false, error = e.message ?: "알 수 없는 오류 발생" ) @@ -93,24 +95,3 @@ class ChatController( } } } - -@Schema(description = "채팅 요청 데이터 모델") -data class ChatRequest( - @Schema(description = "사용자 질문", example = "안녕하세요") - val query: String, - - @Schema(description = "사용할 LLM 모델", example = "gpt-3.5-turbo", defaultValue = "gpt-3.5-turbo") - val model: String = "gpt-3.5-turbo" -) - -@Schema(description = "API 응답 포맷") -data class ApiResponse( - @Schema(description = "요청 처리 성공 여부") - val success: Boolean, - - @Schema(description = "성공 응답 데이터") - val data: T? = null, - - @Schema(description = "실패 오류 메시지") - val error: String? = null -) From 9fc6722e56f271c69a188ec519c5d8da511b544f Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 16:02:54 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=9E=84=EB=B2=A0=EB=94=A9=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DocumentProcessingService.kt | 46 +++++++++++++++++++ .../service/EmbeddingService.kt | 33 +++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/service/DocumentProcessingService.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/service/EmbeddingService.kt diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/DocumentProcessingService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/DocumentProcessingService.kt new file mode 100644 index 0000000..a8a1186 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/DocumentProcessingService.kt @@ -0,0 +1,46 @@ +package com.example.spring_ai_tutorial.service + +import com.example.spring_ai_tutorial.exception.DocumentProcessingException +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.text.PDFTextStripper +import org.springframework.stereotype.Service +import java.io.File +import java.io.IOException + +/** + * 다양한 형식의 문서에서 텍스트를 추출하는 서비스입니다. + * 현재는 PDF 파일 지원, 향후 다른 형식도 추가 가능합니다. + */ +@Service +class DocumentProcessingService { + private val logger = KotlinLogging.logger {} + + /** + * PDF 파일로부터 텍스트를 추출합니다. + * + * @param pdfFile PDF 파일 객체 + * @return 추출된 텍스트 + * @throws DocumentProcessingException 텍스트 추출 실패 시 + */ + fun extractTextFromPdf(pdfFile: File): String { + logger.debug { "PDF 텍스트 추출 시작: ${pdfFile.name}" } + + return try { + // Apache PDFBox를 사용하여 PDF에서 텍스트 추출 + PDDocument.load(pdfFile).use { document -> + logger.debug { "PDF 문서 로드 성공: ${document.numberOfPages}페이지" } + PDFTextStripper().getText(document) + }.also { + logger.debug { "PDF 텍스트 추출 완료: ${it.length} 문자" } + } + } catch (e: IOException) { + logger.error(e) { "PDF 텍스트 추출 실패" } + throw DocumentProcessingException("PDF에서 텍스트 추출 실패: ${e.message}", e) + } + } + + // 향후 다른 문서 형식 지원을 위한 메서드 추가 가능 + // fun extractTextFromDocx(docxFile: File): String { ... } + // fun extractTextFromTxt(txtFile: File): String { ... } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/EmbeddingService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/EmbeddingService.kt new file mode 100644 index 0000000..d0479ef --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/EmbeddingService.kt @@ -0,0 +1,33 @@ +package com.example.spring_ai_tutorial.service + +import org.springframework.ai.document.MetadataMode +import org.springframework.ai.openai.OpenAiEmbeddingModel +import org.springframework.ai.openai.OpenAiEmbeddingOptions +import org.springframework.ai.openai.api.OpenAiApi +import org.springframework.ai.retry.RetryUtils +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +/** + * OpenAI의 임베딩 모델을 사용하여 텍스트를 벡터로 변환합니다. + * Spring AI를 통해 임베딩 모델에 접근합니다. + */ +@Service +class EmbeddingService( + private val openAiApi: OpenAiApi +) { + @Value("\${spring.ai.openai.embedding.options.model}") + private lateinit var embeddingModelName: String + + // OpenAI 임베딩 모델 설정 + val embeddingModel by lazy { + OpenAiEmbeddingModel( + openAiApi, + MetadataMode.EMBED, + OpenAiEmbeddingOptions.builder() + .model(embeddingModelName) + .build(), + RetryUtils.DEFAULT_RETRY_TEMPLATE + ) + } +} \ No newline at end of file From 09d20649781e977ab11e0e5e3f2815b5e58255c1 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 16:03:12 +0900 Subject: [PATCH 10/18] =?UTF-8?q?feat:=20=EC=9D=B8=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=B1=ED=84=B0=20=EC=8A=A4=ED=86=A0=EC=96=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/InMemoryDocumentVectorStore.kt | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt b/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt new file mode 100644 index 0000000..65e67d7 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt @@ -0,0 +1,115 @@ +package com.example.spring_ai_tutorial.repository + +import com.example.spring_ai_tutorial.exception.DocumentProcessingException +import com.example.spring_ai_tutorial.service.DocumentProcessingService +import com.example.spring_ai_tutorial.domain.dto.DocumentSearchResultDto +import com.example.spring_ai_tutorial.service.EmbeddingService +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.ai.document.Document +import org.springframework.ai.vectorstore.SimpleVectorStore +import org.springframework.ai.vectorstore.VectorStore +import org.springframework.stereotype.Repository +import java.io.File +import org.springframework.ai.vectorstore.SearchRequest + +/** + * 문서를 벡터화하여 저장하고, 벡터 유사도 검색을 제공합니다. + * Spring AI의 SimpleVectorStore를 활용합니다. + */ +@Repository +class InMemoryDocumentVectorStore( + private val embeddingService: EmbeddingService, + private val documentProcessingService: DocumentProcessingService, +) { + private val logger = KotlinLogging.logger {} + + // Spring AI의 인메모리 SimpleVectorStore 생성 + private val vectorStore: VectorStore = SimpleVectorStore.builder(embeddingService.embeddingModel).build() + + /** + * 문서를 벡터 스토어에 추가합니다. + * + * @param id 문서 식별자 + * @param fileText 문서 내용 + * @param metadata 문서 메타데이터 + */ + fun addDocument(id: String, fileText: String, metadata: Map) { + logger.debug { "문서 추가 시작 - ID: $id, 내용 길이: ${fileText.length}" } + + try { + // Spring AI Document 객체 생성 + val document = Document(fileText, metadata + mapOf("id" to id)) + + // 벡터 스토어에 문서 추가 (내부적으로 임베딩 변환 수행) + vectorStore.add(listOf(document)) + + logger.info { "문서 추가 완료 - ID: $id" } + } catch (e: Exception) { + logger.error(e) { "문서 추가 실패 - ID: $id" } + throw DocumentProcessingException("문서 임베딩 및 저장 실패: ${e.message}", e) + } + } + + /** + * 파일을 처리하여 벡터 스토어에 추가합니다. + * + * @param id 문서 식별자 + * @param file 파일 객체 + * @param metadata 문서 메타데이터 + */ + fun addDocumentFile(id: String, file: File, metadata: Map) { + logger.debug { "파일 문서 추가 시작 - ID: $id, 파일: ${file.name}" } + + try { + // 텍스트 추출 + val fileText = if (file.extension.lowercase() == "pdf") { + documentProcessingService.extractTextFromPdf(file) + } else { + file.readText() + } + + logger.debug { "파일 텍스트 추출 완료 - 길이: ${fileText.length}" } + addDocument(id, fileText, metadata) + } catch (e: Exception) { + logger.error(e) { "파일 처리 실패 - ID: $id, 파일: ${file.name}" } + throw DocumentProcessingException("파일 처리 실패: ${e.message}", e) + } + } + + /** + * 질의와 유사한 문서를 검색합니다. + * + * @param query 검색 질의 + * @param maxResults 최대 결과 수 + * @return 유사도 순으로 정렬된 검색 결과 목록 + */ + fun similaritySearch(query: String, maxResults: Int): List { + logger.debug { "유사도 검색 시작 - 질의: '$query', 최대 결과: $maxResults" } + + try { + // 검색 요청 구성 + val request = SearchRequest.builder() + .query(query) + .topK(maxResults) + .build() + + // 유사성 검색 실행 + val results = vectorStore.similaritySearch(request) ?: emptyList() + + logger.debug { "유사도 검색 완료 - 결과 수: ${results.size}" } + + // 결과 매핑 + return results.map { result -> + DocumentSearchResultDto( + id = (result.metadata["id"] ?: "unknown").toString(), + content = result.text ?: "", + metadata = result.metadata.filter { it.key != "id" }, + score = result.score ?: 0.0 + ) + } + } catch (e: Exception) { + logger.error(e) { "유사도 검색 실패 - 질의: '$query'" } + throw DocumentProcessingException("유사도 검색 중 오류 발생: ${e.message}", e) + } + } +} \ No newline at end of file From 8e88ad409f6255769e71b8bb08b7f9e7275d2b44 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 16:03:29 +0900 Subject: [PATCH 11/18] =?UTF-8?q?feat:=20RAG=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EB=B0=8F=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RagController.kt | 179 ++++++++++++++++++ .../spring_ai_tutorial/service/RagService.kt | 127 +++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt create mode 100644 src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt b/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt new file mode 100644 index 0000000..6e6896d --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt @@ -0,0 +1,179 @@ +package com.example.spring_ai_tutorial.controller + +import com.example.spring_ai_tutorial.domain.dto.* +import com.example.spring_ai_tutorial.service.RagService +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile +import java.io.File +import java.io.IOException + +/** + * RAG(Retrieval-Augmented Generation) API 컨트롤러 + * + * PDF 문서 업로드 및 질의응답 기능을 제공합니다. + */ +@RestController +@RequestMapping("/api/v1/rag") +@Tag(name = "RAG API", description = "Retrieval-Augmented Generation 기능을 위한 API") +class RagController(private val ragService: RagService) { + private val logger = KotlinLogging.logger {} + + /** + * PDF 문서를 업로드하여 벡터 스토어에 저장합니다. + */ + @Operation( + summary = "PDF 문서 업로드", + description = "PDF 파일을 업로드하여 벡터 스토어에 저장합니다. 추후 질의에 활용됩니다." + ) + @SwaggerResponse( + responseCode = "200", + description = "문서 업로드 성공", + content = [Content(schema = Schema(implementation = ApiResponseDto::class))] + ) + @SwaggerResponse(responseCode = "400", description = "잘못된 요청 (빈 파일 또는 PDF가 아닌 파일)") + @SwaggerResponse(responseCode = "500", description = "서버 오류") + @PostMapping("/documents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + suspend fun uploadDocument( + @Parameter(description = "업로드할 PDF 파일", required = true) + @RequestParam("file") file: MultipartFile + ): ResponseEntity> { + logger.info { "문서 업로드 요청 받음: ${file.originalFilename}" } + + // 유효성 검사 + if (file.isEmpty) { + logger.warn { "빈 파일이 업로드됨" } + return ResponseEntity.badRequest().body( + ApiResponseDto(success = false, error = "파일이 비어있습니다.") + ) + } + file.originalFilename?.takeIf { it.lowercase().endsWith(".pdf") } ?: run { + logger.warn { "지원하지 않는 파일 형식: ${file.originalFilename}" } + return ResponseEntity.badRequest().body( + ApiResponseDto(success = false, error = "PDF 파일만 업로드 가능합니다.") + ) + } + + // File 객체 생성 + val tempFile = withContext(Dispatchers.IO) { + try { + File.createTempFile("upload_", ".pdf").also { + logger.debug { "임시 파일 생성됨: ${it.absolutePath}" } + file.transferTo(it) + } + } catch (e: IOException) { + logger.error(e) { "임시 파일 생성 실패" } + return@withContext null + } + } + + if (tempFile == null) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponseDto(success = false, error = "파일 처리 중 오류가 발생했습니다.") + ) + } + + // 문서 처리 및 응답 + return try { + val documentId = withContext(Dispatchers.IO) { + ragService.uploadPdfFile(tempFile, file.originalFilename) + } + + logger.info { "문서 업로드 성공: $documentId" } + ResponseEntity.ok( + ApiResponseDto( + success = true, + data = DocumentUploadResultDto( + documentId = documentId, + message = "문서가 성공적으로 업로드되었습니다." + ) + ) + ) + } catch (e: Exception) { + logger.error(e) { "문서 처리 중 오류 발생" } + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponseDto(success = false, error = "문서 처리 중 오류가 발생했습니다: ${e.message}") + ) + } finally { + withContext(Dispatchers.IO) { + if (tempFile.exists()) { + tempFile.delete() + logger.debug { "임시 파일 삭제됨: ${tempFile.absolutePath}" } + } + } + } + } + + /** + * 사용자 질의에 대해 관련 문서를 검색하고 RAG 기반 응답을 생성합니다. + */ + @Operation( + summary = "RAG 질의 수행", + description = "사용자 질문에 대해 관련 문서를 검색하고 RAG 기반 응답을 생성합니다." + ) + @SwaggerResponse( + responseCode = "200", + description = "질의 성공", + content = [Content(schema = Schema(implementation = ApiResponseDto::class))] + ) + @SwaggerResponse(responseCode = "400", description = "잘못된 요청") + @SwaggerResponse(responseCode = "500", description = "서버 오류") + @PostMapping("/query") + suspend fun queryWithRag( + @Parameter(description = "질의 요청 객체", required = true) + @RequestBody request: QueryRequestDto + ): ResponseEntity> { + logger.info { "RAG 질의 요청 받음: ${request.query}" } + + // 유효성 검사 + if (request.query.isBlank()) { + logger.warn { "빈 질의가 요청됨" } + return ResponseEntity.badRequest().body( + ApiResponseDto(success = false, error = "질의가 비어있습니다.") + ) + } + + return try { + // 관련 문서 검색 + val relevantDocs = withContext(Dispatchers.IO) { + ragService.retrieve(request.query, request.maxResults ?: 5) + } + + // RAG 기반 응답 생성 + val answer = withContext(Dispatchers.IO) { + ragService.generateAnswerWithContexts( + request.query, + relevantDocs, + request.model ?: "gpt-3.5-turbo" + ) + } + + ResponseEntity.ok( + ApiResponseDto( + success = true, + data = QueryResponseDto( + query = request.query, + answer = answer, + relevantDocuments = relevantDocs.map { it.toDocumentResponseDto() } + ) + ) + ) + } catch (e: Exception) { + logger.error(e) { "RAG 질의 처리 중 오류 발생" } + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponseDto(success = false, error = "질의 처리 중 오류가 발생했습니다: ${e.message}") + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt new file mode 100644 index 0000000..33d764a --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt @@ -0,0 +1,127 @@ +package com.example.spring_ai_tutorial.service + +import com.example.spring_ai_tutorial.exception.DocumentProcessingException +import com.example.spring_ai_tutorial.repository.InMemoryDocumentVectorStore +import com.example.spring_ai_tutorial.domain.dto.DocumentSearchResultDto +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import java.io.File +import java.util.UUID + +/** + * 문서 업로드, 검색, 그리고 검색 결과를 활용한 LLM 응답 생성을 담당합니다. + */ +@Service +class RagService( + private val vectorStore: InMemoryDocumentVectorStore, + private val chatService: ChatService, +) { + private val logger = KotlinLogging.logger {} + + /** + * PDF 파일을 업로드하여 벡터 스토어에 추가합니다. + * + * @param file PDF 파일 + * @param originalFilename 원본 파일명 + * @return 생성된 문서 ID + */ + fun uploadPdfFile( + file: File, + originalFilename: String? + ): String { + val documentId = UUID.randomUUID().toString() + logger.info { "PDF 문서 업로드 시작. 파일: $originalFilename, ID: $documentId" } + + // 메타데이터 준비 + val docMetadata = HashMap().apply { + put("originalFilename", originalFilename ?: "") + put("uploadTime", System.currentTimeMillis()) + } + + // 벡터 스토어에 문서 추가 + try { + vectorStore.addDocumentFile(documentId, file, docMetadata) + logger.info { "PDF 문서 업로드 완료. ID: $documentId" } + return documentId + } catch (e: Exception) { + logger.error(e) { "문서 처리 중 오류 발생: ${e.message}" } + throw DocumentProcessingException("문서 처리 중 오류: ${e.message}", e) + } + } + + /** + * 질의와 관련된 문서를 검색합니다. + * + * @param question 사용자 질문 + * @param maxResults 최대 검색 결과 수 + * @return 관련도 순으로 정렬된 문서 목록 + */ + fun retrieve(question: String, maxResults: Int = 5): List { + logger.debug { "검색 시작: '$question', 최대 결과 수: $maxResults" } + return vectorStore.similaritySearch(question, maxResults) + } + + /** + * 질문에 대한 답변을 생성하며, 참고한 정보 출처도 함께 제공합니다. + * + * @param question 사용자 질문 + * @param relevantDocs 이미 검색된 관련 문서 + * @param model 사용할 LLM 모델명 + * @return 참고 출처가 포함된 응답 + */ + suspend fun generateAnswerWithContexts( + question: String, + relevantDocs: List, + model: String = "gpt-3.5-turbo" + ): String { + logger.debug { "RAG 응답 생성 시작: '$question', 모델: $model" } + + // 관련 문서 검색 또는 사용 + if (relevantDocs.isEmpty()) { + logger.info { "관련 정보를 찾을 수 없음: '$question'" } + return "관련 정보를 찾을 수 없습니다. 다른 질문을 시도하거나 관련 문서를 업로드해 주세요." + } + + // 문서 번호 부여 (응답에서 출처 표시를 위해) + val numberedDocs = relevantDocs.mapIndexed { index, doc -> + "[${index + 1}] ${doc.content}" + } + + // 관련 문서의 내용을 컨텍스트로 결합 + val context = numberedDocs.joinToString("\n\n") + logger.debug { "컨텍스트 크기: ${context.length} 문자" } + + // 컨텍스트를 포함하는 시스템 프롬프트 생성 + val systemPromptText = """ + 당신은 지식 기반 Q&A 시스템입니다. + 사용자의 질문에 대한 답변을 다음 정보를 바탕으로 생성해주세요. + 주어진 정보에 답이 없다면 모른다고 솔직히 말해주세요. + 답변 마지막에 사용한 정보의 출처 번호 [1], [2] 등을 반드시 포함해주세요. + + 정보: + $context + """.trimIndent() + + // LLM을 통한 응답 생성 + try { + val response = chatService.openAiChat(question, systemPromptText, model) + logger.debug { "AI 응답 생성: ${response}" } + val aiAnswer = response?.result?.output?.text ?: "응답을 생성할 수 없습니다." + + // 참고 문서 정보 추가 + val sourceInfo = buildString { + appendLine("\n\n참고 문서:") + relevantDocs.forEachIndexed { index, doc -> + val originalFilename = doc.metadata["originalFilename"]?.toString() ?: "Unknown file" + appendLine("[${index + 1}] $originalFilename") + } + } + + return aiAnswer + sourceInfo + } catch (e: Exception) { + logger.error(e) { "AI 모델 호출 중 오류 발생: ${e.message}" } + return "AI 모델 호출 중 오류가 발생했습니다. 검색 결과만 제공합니다:\n\n" + + relevantDocs.joinToString("\n\n") { it.content } + } + } +} \ No newline at end of file From 73eaf7a36c6e65ab90b190dab36785a738fcf98e Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 10 May 2025 16:12:12 +0900 Subject: [PATCH 12/18] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20Elvis=20=EC=97=B0=EC=82=B0=EC=9E=90=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/spring_ai_tutorial/controller/RagController.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt b/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt index 6e6896d..fd54fd9 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt @@ -147,7 +147,7 @@ class RagController(private val ragService: RagService) { return try { // 관련 문서 검색 val relevantDocs = withContext(Dispatchers.IO) { - ragService.retrieve(request.query, request.maxResults ?: 5) + ragService.retrieve(request.query, request.maxResults) } // RAG 기반 응답 생성 @@ -155,7 +155,7 @@ class RagController(private val ragService: RagService) { ragService.generateAnswerWithContexts( request.query, relevantDocs, - request.model ?: "gpt-3.5-turbo" + request.model ) } From f30ac4d44c0b648c54df4cc882f79fbd10b909ee Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 31 May 2025 01:51:56 +0900 Subject: [PATCH 13/18] =?UTF-8?q?refactor:=20=EC=BD=94=EB=A3=A8=ED=8B=B4?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatController.kt | 2 +- .../controller/RagController.kt | 52 +++++++------------ .../spring_ai_tutorial/service/ChatService.kt | 10 ++-- .../spring_ai_tutorial/service/RagService.kt | 2 +- 4 files changed, 24 insertions(+), 42 deletions(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt b/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt index 576b4ad..017d4b3 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt @@ -42,7 +42,7 @@ class ChatController( @SwaggerResponse(responseCode = "400", description = "잘못된 요청") @SwaggerResponse(responseCode = "500", description = "서버 오류") @PostMapping("/query") - suspend fun sendMessage( + fun sendMessage( @Parameter(description = "채팅 요청 객체", required = true) @RequestBody request: ChatRequestDto ): ResponseEntity>> { diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt b/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt index fd54fd9..6bfcb1e 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt @@ -9,8 +9,6 @@ import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerResponse -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -45,7 +43,7 @@ class RagController(private val ragService: RagService) { @SwaggerResponse(responseCode = "400", description = "잘못된 요청 (빈 파일 또는 PDF가 아닌 파일)") @SwaggerResponse(responseCode = "500", description = "서버 오류") @PostMapping("/documents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) - suspend fun uploadDocument( + fun uploadDocument( @Parameter(description = "업로드할 PDF 파일", required = true) @RequestParam("file") file: MultipartFile ): ResponseEntity> { @@ -66,19 +64,13 @@ class RagController(private val ragService: RagService) { } // File 객체 생성 - val tempFile = withContext(Dispatchers.IO) { - try { - File.createTempFile("upload_", ".pdf").also { - logger.debug { "임시 파일 생성됨: ${it.absolutePath}" } - file.transferTo(it) - } - } catch (e: IOException) { - logger.error(e) { "임시 파일 생성 실패" } - return@withContext null + val tempFile = try { + File.createTempFile("upload_", ".pdf").also { + logger.debug { "임시 파일 생성됨: ${it.absolutePath}" } + file.transferTo(it) } - } - - if (tempFile == null) { + } catch (e: IOException) { + logger.error(e) { "임시 파일 생성 실패" } return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( ApiResponseDto(success = false, error = "파일 처리 중 오류가 발생했습니다.") ) @@ -86,9 +78,7 @@ class RagController(private val ragService: RagService) { // 문서 처리 및 응답 return try { - val documentId = withContext(Dispatchers.IO) { - ragService.uploadPdfFile(tempFile, file.originalFilename) - } + val documentId = ragService.uploadPdfFile(tempFile, file.originalFilename) logger.info { "문서 업로드 성공: $documentId" } ResponseEntity.ok( @@ -106,11 +96,9 @@ class RagController(private val ragService: RagService) { ApiResponseDto(success = false, error = "문서 처리 중 오류가 발생했습니다: ${e.message}") ) } finally { - withContext(Dispatchers.IO) { - if (tempFile.exists()) { - tempFile.delete() - logger.debug { "임시 파일 삭제됨: ${tempFile.absolutePath}" } - } + if (tempFile.exists()) { + tempFile.delete() + logger.debug { "임시 파일 삭제됨: ${tempFile.absolutePath}" } } } } @@ -130,7 +118,7 @@ class RagController(private val ragService: RagService) { @SwaggerResponse(responseCode = "400", description = "잘못된 요청") @SwaggerResponse(responseCode = "500", description = "서버 오류") @PostMapping("/query") - suspend fun queryWithRag( + fun queryWithRag( @Parameter(description = "질의 요청 객체", required = true) @RequestBody request: QueryRequestDto ): ResponseEntity> { @@ -146,18 +134,14 @@ class RagController(private val ragService: RagService) { return try { // 관련 문서 검색 - val relevantDocs = withContext(Dispatchers.IO) { - ragService.retrieve(request.query, request.maxResults) - } + val relevantDocs = ragService.retrieve(request.query, request.maxResults) // RAG 기반 응답 생성 - val answer = withContext(Dispatchers.IO) { - ragService.generateAnswerWithContexts( - request.query, - relevantDocs, - request.model - ) - } + val answer = ragService.generateAnswerWithContexts( + request.query, + relevantDocs, + request.model + ) ResponseEntity.ok( ApiResponseDto( diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt index 6d2faf1..f541f8c 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt @@ -1,8 +1,6 @@ package com.example.spring_ai_tutorial.service import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.springframework.ai.chat.messages.SystemMessage import org.springframework.ai.chat.messages.UserMessage import org.springframework.ai.chat.model.ChatResponse @@ -29,11 +27,11 @@ class ChatService( * @param model 사용할 LLM 모델명 * @return 챗 응답 객체, 오류 시 null */ - suspend fun openAiChat( + fun openAiChat( userInput: String, systemMessage: String, model: String = "gpt-3.5-turbo" - ): ChatResponse? = withContext(Dispatchers.IO) { + ): ChatResponse? { logger.debug { "OpenAI 챗 호출 시작 - 모델: $model" } try { // 메시지 구성 @@ -55,10 +53,10 @@ class ChatService( .openAiApi(openAiApi) .build() - return@withContext chatModel.call(prompt) + return chatModel.call(prompt) } catch (e: Exception) { logger.error(e) { "OpenAI 챗 호출 중 오류 발생: ${e.message}" } - return@withContext null + return null } } } \ No newline at end of file diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt index 33d764a..dcaa586 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt @@ -69,7 +69,7 @@ class RagService( * @param model 사용할 LLM 모델명 * @return 참고 출처가 포함된 응답 */ - suspend fun generateAnswerWithContexts( + fun generateAnswerWithContexts( question: String, relevantDocs: List, model: String = "gpt-3.5-turbo" From 6a8b7696d5f227e9dda61a9da5993f0bf4d9a145 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sat, 31 May 2025 01:52:57 +0900 Subject: [PATCH 14/18] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=B2=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A0=84=EB=9E=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/InMemoryDocumentVectorStore.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt b/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt index 65e67d7..aa313cd 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt @@ -6,6 +6,7 @@ import com.example.spring_ai_tutorial.domain.dto.DocumentSearchResultDto import com.example.spring_ai_tutorial.service.EmbeddingService import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.ai.document.Document +import org.springframework.ai.transformer.splitter.TokenTextSplitter import org.springframework.ai.vectorstore.SimpleVectorStore import org.springframework.ai.vectorstore.VectorStore import org.springframework.stereotype.Repository @@ -39,9 +40,17 @@ class InMemoryDocumentVectorStore( try { // Spring AI Document 객체 생성 val document = Document(fileText, metadata + mapOf("id" to id)) + val textSplitter = TokenTextSplitter.builder() + .withChunkSize(512) // 원하는 청크 크기 + .withMinChunkSizeChars(350) // 최소 청크 크기 + .withMinChunkLengthToEmbed(5) // 임베딩할 최소 청크 길이 + .withMaxNumChunks(10000) // 최대 청크 수 + .withKeepSeparator(true) // 구분자 유지 여부 + .build() + val chunks = textSplitter.split(document) - // 벡터 스토어에 문서 추가 (내부적으로 임베딩 변환 수행) - vectorStore.add(listOf(document)) + // 벡터 스토어에 문서 청크 추가 (내부적으로 임베딩 변환 수행) + vectorStore.add(chunks) logger.info { "문서 추가 완료 - ID: $id" } } catch (e: Exception) { From dbff4c2d9e848736d02315732d0e4d05ae188dfe Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Sun, 1 Jun 2025 02:21:57 +0900 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20DocumentResponseDto=20content=205?= =?UTF-8?q?00=EC=9E=90=20=EC=B4=88=EA=B3=BC=EB=B6=84=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt index da3ca69..e81d247 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt @@ -49,7 +49,7 @@ fun DocumentSearchResultDto.toDocumentResponseDto(): DocumentResponseDto { return DocumentResponseDto( id = this.id, score = this.score, - content = this.content.take(100) + if (this.content.length > 100) "..." else "", + content = this.content.take(500) + if (this.content.length > 500) "..." else "", metadata = this.metadata ) } From a5973e95770b0beb2ec276f3184485a5ec8619f7 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Tue, 3 Jun 2025 16:46:13 +0900 Subject: [PATCH 16/18] =?UTF-8?q?chore:=20=EC=BD=94=EB=A3=A8=ED=8B=B4=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 394b401..efac528 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,10 +27,6 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") - // 코루틴 의존성 - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") - // Spring AI 의존성 implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter") From f49f14b39f0f0d4fb8f8e1d0787395da001fdbbd Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Tue, 3 Jun 2025 20:13:43 +0900 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20retrieve()=20maxResults=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=95=84=EC=88=98?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/example/spring_ai_tutorial/service/RagService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt index dcaa586..f5e320d 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt @@ -56,7 +56,7 @@ class RagService( * @param maxResults 최대 검색 결과 수 * @return 관련도 순으로 정렬된 문서 목록 */ - fun retrieve(question: String, maxResults: Int = 5): List { + fun retrieve(question: String, maxResults: Int): List { logger.debug { "검색 시작: '$question', 최대 결과 수: $maxResults" } return vectorStore.similaritySearch(question, maxResults) } From 44fe86ca4d495a597f698600d1f884d02b6b8759 Mon Sep 17 00:00:00 2001 From: ChoHadam Date: Tue, 3 Jun 2025 20:17:18 +0900 Subject: [PATCH 18/18] =?UTF-8?q?chore:=20=EC=9B=8C=EB=94=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt | 4 ++-- .../com/example/spring_ai_tutorial/service/RagService.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt index e81d247..a752c72 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/domain/dto/DocumentDto.kt @@ -20,7 +20,7 @@ data class DocumentSearchResultDto( @Schema(description = "문서 메타데이터") val metadata: Map, - @Schema(description = "관련도 점수") + @Schema(description = "유사도 점수") val score: Double ) @@ -32,7 +32,7 @@ data class DocumentResponseDto( @Schema(description = "문서 ID") val id: String, - @Schema(description = "관련도 점수") + @Schema(description = "유사도 점수") val score: Double, @Schema(description = "문서 내용 (일부)") diff --git a/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt index f5e320d..1319057 100644 --- a/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/RagService.kt @@ -54,7 +54,7 @@ class RagService( * * @param question 사용자 질문 * @param maxResults 최대 검색 결과 수 - * @return 관련도 순으로 정렬된 문서 목록 + * @return 유사도 순으로 정렬된 문서 목록 */ fun retrieve(question: String, maxResults: Int): List { logger.debug { "검색 시작: '$question', 최대 결과 수: $maxResults" }