diff --git a/build.gradle.kts b/build.gradle.kts index 4576e4c..efac528 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,9 +22,24 @@ 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") + + // 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") + + // 로깅 라이브러리 + 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") 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/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..017d4b3 --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/ChatController.kt @@ -0,0 +1,97 @@ +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 +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 = ApiResponseDto::class))] + ) + @SwaggerResponse(responseCode = "400", description = "잘못된 요청") + @SwaggerResponse(responseCode = "500", description = "서버 오류") + @PostMapping("/query") + fun sendMessage( + @Parameter(description = "채팅 요청 객체", required = true) + @RequestBody request: ChatRequestDto + ): ResponseEntity>> { + logger.info { "Chat API 요청 받음: model=${request.model}" } + + // 유효성 검사 + if (request.query.isBlank()) { + logger.warn { "빈 질의가 요청됨" } + return ResponseEntity.badRequest().body( + ApiResponseDto(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( + ApiResponseDto( + success = true, + data = mapOf("answer" to chatResponse.result.output.text) + ) + ) + } ?: run { + logger.error { "LLM 응답 생성 실패" } + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponseDto( + success = false, + error = "LLM 응답 생성 중 오류 발생" + ) + ) + } + } catch (e: Exception) { + logger.error(e) { "Chat API 처리 중 오류 발생" } + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponseDto( + success = false, + error = e.message ?: "알 수 없는 오류 발생" + ) + ) + } + } +} 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..6bfcb1e --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/controller/RagController.kt @@ -0,0 +1,163 @@ +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 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]) + 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 = try { + File.createTempFile("upload_", ".pdf").also { + logger.debug { "임시 파일 생성됨: ${it.absolutePath}" } + file.transferTo(it) + } + } catch (e: IOException) { + logger.error(e) { "임시 파일 생성 실패" } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponseDto(success = false, error = "파일 처리 중 오류가 발생했습니다.") + ) + } + + // 문서 처리 및 응답 + return try { + val documentId = 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 { + 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") + 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 = ragService.retrieve(request.query, request.maxResults) + + // RAG 기반 응답 생성 + val answer = ragService.generateAnswerWithContexts( + request.query, + relevantDocs, + request.model + ) + + 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/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..a752c72 --- /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(500) + if (this.content.length > 500) "..." 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) 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..aa313cd --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/repository/InMemoryDocumentVectorStore.kt @@ -0,0 +1,124 @@ +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.transformer.splitter.TokenTextSplitter +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)) + val textSplitter = TokenTextSplitter.builder() + .withChunkSize(512) // 원하는 청크 크기 + .withMinChunkSizeChars(350) // 최소 청크 크기 + .withMinChunkLengthToEmbed(5) // 임베딩할 최소 청크 길이 + .withMaxNumChunks(10000) // 최대 청크 수 + .withKeepSeparator(true) // 구분자 유지 여부 + .build() + val chunks = textSplitter.split(document) + + // 벡터 스토어에 문서 청크 추가 (내부적으로 임베딩 변환 수행) + vectorStore.add(chunks) + + 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 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..f541f8c --- /dev/null +++ b/src/main/kotlin/com/example/spring_ai_tutorial/service/ChatService.kt @@ -0,0 +1,62 @@ +package com.example.spring_ai_tutorial.service + +import io.github.oshai.kotlinlogging.KotlinLogging +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 + +/** + * 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 + */ + fun openAiChat( + userInput: String, + systemMessage: String, + model: String = "gpt-3.5-turbo" + ): ChatResponse? { + 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 chatModel.call(prompt) + } catch (e: Exception) { + logger.error(e) { "OpenAI 챗 호출 중 오류 발생: ${e.message}" } + return null + } + } +} \ No newline at end of file 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 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..1319057 --- /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): List { + logger.debug { "검색 시작: '$question', 최대 결과 수: $maxResults" } + return vectorStore.similaritySearch(question, maxResults) + } + + /** + * 질문에 대한 답변을 생성하며, 참고한 정보 출처도 함께 제공합니다. + * + * @param question 사용자 질문 + * @param relevantDocs 이미 검색된 관련 문서 + * @param model 사용할 LLM 모델명 + * @return 참고 출처가 포함된 응답 + */ + 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 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3b15fda..ccaf497 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,15 @@ spring.application.name=spring-ai-tutorial + +# Server Configuration +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 +spring.servlet.multipart.max-request-size=20MB + +# Swagger/OpenAPI Configuration +springdoc.api-docs.path=/api-docs