From 7d9069de1a6ae664a7823bdb5f0fd63f4ef82531 Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Fri, 12 Dec 2025 02:54:34 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=80=EA=B2=BD2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../ragone/controller/DocumentController.java | 40 +++ .../java/kr/co/ragone/domain/DocChunk.java | 14 + .../repository/ChatMessageRepository.java | 18 ++ .../ragone/repository/DocChunkRepository.java | 43 ++- .../kr/co/ragone/service/ChatService.java | 274 ++++++++++++----- .../kr/co/ragone/service/ChunkingService.java | 36 ++- .../service/DocumentIndexingService.java | 89 +++++- .../co/ragone/service/EmbeddingService.java | 7 +- .../ragone/service/SmartChunkingService.java | 288 ++++++++++++++++++ .../kr/co/ragone/service/VisionService.java | 212 +++++++++++++ src/main/resources/application.yml | 87 ++++-- 12 files changed, 994 insertions(+), 117 deletions(-) create mode 100644 src/main/java/kr/co/ragone/service/SmartChunkingService.java create mode 100644 src/main/java/kr/co/ragone/service/VisionService.java diff --git a/build.gradle b/build.gradle index c5a66d3..7f696cf 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,9 @@ dependencies { implementation 'org.apache.tika:tika-core:2.9.1' implementation 'org.apache.tika:tika-parsers-standard-package:2.9.1' + // PDF 이미지 변환 (Vision 처리용) + implementation 'org.apache.pdfbox:pdfbox:2.0.31' + // 유틸리티 compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/kr/co/ragone/controller/DocumentController.java b/src/main/java/kr/co/ragone/controller/DocumentController.java index 55e0412..b7b2b66 100644 --- a/src/main/java/kr/co/ragone/controller/DocumentController.java +++ b/src/main/java/kr/co/ragone/controller/DocumentController.java @@ -1,6 +1,8 @@ package kr.co.ragone.controller; +import kr.co.ragone.domain.DocChunk; import kr.co.ragone.domain.DocInfo; +import kr.co.ragone.repository.DocChunkRepository; import kr.co.ragone.repository.DocInfoRepository; import kr.co.ragone.service.DocumentIndexingService; import lombok.RequiredArgsConstructor; @@ -27,6 +29,7 @@ public class DocumentController { private final DocumentIndexingService documentIndexingService; private final DocInfoRepository docInfoRepository; + private final DocChunkRepository docChunkRepository; /** * 문서 업로드 및 인덱싱 @@ -129,6 +132,43 @@ public class DocumentController { } } + /** + * 문서별 청크 목록 조회 + */ + @GetMapping("/documents/{docId}/chunks") + public ResponseEntity> getChunks(@PathVariable Long docId) { + List chunks = docChunkRepository.findByDocInfo_DocId(docId); + + List response = chunks.stream() + .map(chunk -> ChunkResponse.builder() + .chunkId(chunk.getChunkId()) + .chunkIndex(chunk.getChunkIndex()) + .chunkContent(chunk.getChunkContent()) + .tokenCount(chunk.getTokenCount()) + .chunkSummary(chunk.getChunkSummary()) + .chunkKeywords(chunk.getChunkKeywords()) + .chunkQuestions(chunk.getChunkQuestions()) + .chunkType(chunk.getChunkType()) + .build()) + .toList(); + + return ResponseEntity.ok(response); + } + + @lombok.Data + @lombok.Builder + public static class ChunkResponse { + private Long chunkId; + private Integer chunkIndex; + private String chunkContent; + private Integer tokenCount; + // 스마트 청킹 메타데이터 + private String chunkSummary; // 청크 요약 + private String chunkKeywords; // 키워드 (쉼표 구분) + private String chunkQuestions; // 예상 질문 (JSON) + private String chunkType; // 타입 (text, image, table) + } + /** * 파일 확장자에 따른 Content-Type 반환 */ diff --git a/src/main/java/kr/co/ragone/domain/DocChunk.java b/src/main/java/kr/co/ragone/domain/DocChunk.java index 4dfe5a3..b742c3c 100644 --- a/src/main/java/kr/co/ragone/domain/DocChunk.java +++ b/src/main/java/kr/co/ragone/domain/DocChunk.java @@ -49,4 +49,18 @@ public class DocChunk { @Column(name = "created_at") @Builder.Default private LocalDateTime createdAt = LocalDateTime.now(); + + // LLM 기반 메타데이터 + @Column(name = "chunk_summary", columnDefinition = "TEXT") + private String chunkSummary; // 청크 요약 + + @Column(name = "chunk_keywords") + private String chunkKeywords; // 핵심 키워드 (쉼표 구분) + + @Column(name = "chunk_questions", columnDefinition = "TEXT") + private String chunkQuestions; // 예상 질문 (JSON 배열) + + @Column(name = "chunk_type", length = 20) + @Builder.Default + private String chunkType = "text"; // text, image, table } diff --git a/src/main/java/kr/co/ragone/repository/ChatMessageRepository.java b/src/main/java/kr/co/ragone/repository/ChatMessageRepository.java index fe7c506..36080e1 100644 --- a/src/main/java/kr/co/ragone/repository/ChatMessageRepository.java +++ b/src/main/java/kr/co/ragone/repository/ChatMessageRepository.java @@ -2,6 +2,8 @@ package kr.co.ragone.repository; import kr.co.ragone.domain.ChatMessage; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +12,20 @@ import java.util.List; public interface ChatMessageRepository extends JpaRepository { List findByChatSession_SessionIdOrderByCreatedAtAsc(Long sessionId); + + /** + * 세션의 최근 N개 메시지 조회 (대화 맥락 유지용) + */ + @Query(value = """ + SELECT * FROM ( + SELECT * FROM tb_chat_message + WHERE session_id = :sessionId + ORDER BY created_at DESC + LIMIT :limit + ) sub ORDER BY created_at ASC + """, nativeQuery = true) + List findRecentMessages( + @Param("sessionId") Long sessionId, + @Param("limit") int limit + ); } diff --git a/src/main/java/kr/co/ragone/repository/DocChunkRepository.java b/src/main/java/kr/co/ragone/repository/DocChunkRepository.java index 7375f21..9b49b9c 100644 --- a/src/main/java/kr/co/ragone/repository/DocChunkRepository.java +++ b/src/main/java/kr/co/ragone/repository/DocChunkRepository.java @@ -16,13 +16,20 @@ public interface DocChunkRepository extends JpaRepository { void deleteByDocInfo_DocId(Long docId); /** - * 벡터 유사도 검색 (전체 주제) + * 벡터 유사도 검색 (전체 주제) - 문서 정보 포함 + * + * 반환 컬럼: + * [0] chunk_id, [1] doc_id, [2] topic_id, [3] chunk_content, + * [4] chunk_index, [5] token_count, [6] chunk_metadata, [7] created_at, + * [8] similarity, [9] original_name, [10] file_type, [11] chunk_count */ @Query(value = """ SELECT c.chunk_id, c.doc_id, c.topic_id, c.chunk_content, c.chunk_index, c.token_count, c.chunk_metadata, c.created_at, - 1 - (c.chunk_embedding <=> cast(:embedding as vector)) as similarity + 1 - (c.chunk_embedding <=> cast(:embedding as vector)) as similarity, + d.original_name, d.file_type, d.chunk_count FROM TB_DOC_CHUNK c + JOIN TB_DOC_INFO d ON c.doc_id = d.doc_id WHERE 1 - (c.chunk_embedding <=> cast(:embedding as vector)) > :threshold ORDER BY c.chunk_embedding <=> cast(:embedding as vector) LIMIT :limit @@ -34,21 +41,45 @@ public interface DocChunkRepository extends JpaRepository { ); /** - * 벡터 유사도 검색 (특정 주제들) + * 벡터 유사도 검색 (특정 주제 - 단일) - 문서 정보 포함 */ @Query(value = """ SELECT c.chunk_id, c.doc_id, c.topic_id, c.chunk_content, c.chunk_index, c.token_count, c.chunk_metadata, c.created_at, - 1 - (c.chunk_embedding <=> cast(:embedding as vector)) as similarity + 1 - (c.chunk_embedding <=> cast(:embedding as vector)) as similarity, + d.original_name, d.file_type, d.chunk_count FROM TB_DOC_CHUNK c - WHERE c.topic_id = ANY(cast(:topicIds as BIGINT[])) + JOIN TB_DOC_INFO d ON c.doc_id = d.doc_id + WHERE c.topic_id = :topicId + AND 1 - (c.chunk_embedding <=> cast(:embedding as vector)) > :threshold + ORDER BY c.chunk_embedding <=> cast(:embedding as vector) + LIMIT :limit + """, nativeQuery = true) + List findSimilarChunksByTopic( + @Param("embedding") String embedding, + @Param("topicId") Long topicId, + @Param("threshold") double threshold, + @Param("limit") int limit + ); + + /** + * 벡터 유사도 검색 (특정 주제들 - 복수) - 문서 정보 포함 + */ + @Query(value = """ + SELECT c.chunk_id, c.doc_id, c.topic_id, c.chunk_content, + c.chunk_index, c.token_count, c.chunk_metadata, c.created_at, + 1 - (c.chunk_embedding <=> cast(:embedding as vector)) as similarity, + d.original_name, d.file_type, d.chunk_count + FROM TB_DOC_CHUNK c + JOIN TB_DOC_INFO d ON c.doc_id = d.doc_id + WHERE c.topic_id IN (:topicIds) AND 1 - (c.chunk_embedding <=> cast(:embedding as vector)) > :threshold ORDER BY c.chunk_embedding <=> cast(:embedding as vector) LIMIT :limit """, nativeQuery = true) List findSimilarChunksByTopics( @Param("embedding") String embedding, - @Param("topicIds") Long[] topicIds, + @Param("topicIds") List topicIds, @Param("threshold") double threshold, @Param("limit") int limit ); diff --git a/src/main/java/kr/co/ragone/service/ChatService.java b/src/main/java/kr/co/ragone/service/ChatService.java index bc0d691..2216632 100644 --- a/src/main/java/kr/co/ragone/service/ChatService.java +++ b/src/main/java/kr/co/ragone/service/ChatService.java @@ -1,9 +1,8 @@ package kr.co.ragone.service; -import com.theokanning.openai.completion.chat.ChatCompletionRequest; -import com.theokanning.openai.completion.chat.ChatCompletionResult; -import com.theokanning.openai.completion.chat.ChatMessage; -import com.theokanning.openai.service.OpenAiService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.ragone.domain.ChatMessage; import kr.co.ragone.domain.ChatSession; import kr.co.ragone.repository.ChatMessageRepository; import kr.co.ragone.repository.ChatSessionRepository; @@ -11,13 +10,13 @@ import kr.co.ragone.repository.DocChunkRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; @Slf4j @@ -25,11 +24,15 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class ChatService { - private final OpenAiService openAiService; private final EmbeddingService embeddingService; private final DocChunkRepository docChunkRepository; private final ChatSessionRepository chatSessionRepository; private final ChatMessageRepository chatMessageRepository; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${openai.api-key}") + private String apiKey; @Value("${openai.model.chat}") private String chatModel; @@ -40,24 +43,37 @@ public class ChatService { @Value("${rag.retrieval.similarity-threshold}") private double similarityThreshold; + private static final String OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"; + private static final int MAX_HISTORY_MESSAGES = 6; // 최근 대화 6개 (3턴) + /** - * RAG 기반 질의응답 (세션 저장 포함) + * RAG 기반 질의응답 (세션 저장 + 대화 맥락 유지) */ @Transactional public RagResponse ask(String question, List topicIds, String sessionKey) { // 1. 세션 조회 또는 생성 ChatSession session = getOrCreateSession(sessionKey, question); - // 2. 사용자 메시지 저장 + // 2. 이전 대화 히스토리 조회 + List history = getConversationHistory(session); + log.info("[RAG] 이전 대화 {}개 로드됨", history.size()); + + // 3. 사용자 메시지 저장 saveMessage(session, "user", question, topicIds, null); - // 3. 질문 임베딩 - String questionEmbedding = embeddingService.createEmbeddingAsString(question); - log.info("[RAG] Question: {}", question); + // 4. 검색 쿼리 구성 (맥락 기반) + String searchQuery = buildSearchQuery(question, history); + log.info("[RAG] ============ START ============"); + log.info("[RAG] 원본 질문: {}", question); + log.info("[RAG] 검색 쿼리: {}", searchQuery); log.info("[RAG] TopicIds: {}", topicIds); + + // 5. 질문 임베딩 (확장된 쿼리로) + String questionEmbedding = embeddingService.createEmbeddingAsString(searchQuery); + log.info("[RAG] Embedding created, length: {}", questionEmbedding.length()); log.info("[RAG] Threshold: {}, TopK: {}", similarityThreshold, topK); - // 4. 유사 문서 검색 + // 6. 유사 문서 검색 List chunks; if (topicIds == null || topicIds.isEmpty()) { log.info("[RAG] Searching ALL topics"); @@ -66,7 +82,7 @@ public class ChatService { } else { log.info("[RAG] Searching specific topics: {}", topicIds); chunks = docChunkRepository.findSimilarChunksByTopics( - questionEmbedding, topicIds.toArray(new Long[0]), + questionEmbedding, topicIds, similarityThreshold, topK); } log.info("[RAG] Found {} relevant chunks", chunks.size()); @@ -81,17 +97,17 @@ public class ChatService { content.substring(0, Math.min(100, content.length()))); } - // 5. 컨텍스트 구성 + // 7. 컨텍스트 구성 String context = buildContext(chunks); - // 6. 프롬프트 구성 및 GPT 호출 - String answer = generateAnswer(question, context, chunks.isEmpty()); + // 8. 프롬프트 구성 및 GPT 호출 (대화 히스토리 포함) + String answer = generateAnswerWithHistory(question, context, history, chunks.isEmpty()); - // 7. AI 응답 메시지 저장 + // 9. AI 응답 메시지 저장 List sources = extractSources(chunks); saveMessage(session, "assistant", answer, topicIds, sources); - // 8. 응답 구성 + // 10. 응답 구성 return RagResponse.builder() .sessionKey(session.getSessionKey()) .answer(answer) @@ -99,6 +115,46 @@ public class ChatService { .build(); } + /** + * 이전 대화 히스토리 조회 + */ + private List getConversationHistory(ChatSession session) { + if (session.getSessionId() == null) { + return Collections.emptyList(); + } + return chatMessageRepository.findRecentMessages(session.getSessionId(), MAX_HISTORY_MESSAGES); + } + + /** + * 맥락 기반 검색 쿼리 구성 + * - 짧은 질문(예: "기간은?")인 경우 이전 대화의 키워드를 추가 + */ + private String buildSearchQuery(String question, List history) { + // 질문이 충분히 길면 그대로 사용 + if (question.length() > 15 || history.isEmpty()) { + return question; + } + + // 이전 대화에서 맥락 추출 + StringBuilder contextBuilder = new StringBuilder(); + for (ChatMessage msg : history) { + if ("user".equals(msg.getMsgRole())) { + contextBuilder.append(msg.getMsgContent()).append(" "); + } + } + + // 이전 질문들 + 현재 질문 조합 + String previousContext = contextBuilder.toString().trim(); + if (previousContext.length() > 200) { + previousContext = previousContext.substring(previousContext.length() - 200); + } + + String expandedQuery = previousContext + " " + question; + log.info("[RAG] 검색 쿼리 확장: '{}' -> '{}'", question, expandedQuery.trim()); + + return expandedQuery.trim(); + } + /** * 세션 조회 또는 생성 */ @@ -129,7 +185,7 @@ public class ChatService { */ private void saveMessage(ChatSession session, String role, String content, List topicIds, List sources) { - kr.co.ragone.domain.ChatMessage message = kr.co.ragone.domain.ChatMessage.builder() + ChatMessage message = ChatMessage.builder() .chatSession(session) .msgRole(role) .msgContent(content) @@ -153,8 +209,12 @@ public class ChatService { Object[] row = chunks.get(i); String content = (String) row[3]; // chunk_content Double similarity = ((Number) row[8]).doubleValue(); // similarity + String docName = (String) row[9]; // original_name - sb.append(String.format("【문서 %d】 (관련도: %.0f%%)\n", i + 1, similarity * 100)); + sb.append(String.format("【문서 %d】 %s (관련도: %.0f%%)\n", + i + 1, + docName != null ? docName : "", + similarity * 100)); sb.append("─".repeat(40)).append("\n"); sb.append(content.trim()); sb.append("\n\n"); @@ -162,35 +222,32 @@ public class ChatService { return sb.toString(); } - private String generateAnswer(String question, String context, boolean noContext) { - String systemPrompt; - + /** + * 대화 히스토리를 포함한 답변 생성 + */ + private String generateAnswerWithHistory(String question, String context, + List history, boolean noContext) { + // 문서 없으면 바로 안내 메시지 반환 (GPT 호출 없이) if (noContext) { - systemPrompt = """ - 당신은 친절한 문서 기반 질의응답 어시스턴트입니다. - 현재 검색된 관련 문서가 없습니다. - - 사용자에게 다음을 안내해주세요: - 1. 해당 질문과 관련된 문서가 시스템에 등록되어 있지 않을 수 있습니다. - 2. 더 구체적인 키워드로 질문하면 도움이 될 수 있습니다. - 3. 관리자에게 관련 문서 등록을 요청할 수 있습니다. - - 단, 일반적인 상식이나 공개된 정보로 답변 가능한 경우 도움을 드릴 수 있습니다. - """; - } else { - systemPrompt = """ + return "죄송합니다. 질문과 관련된 문서를 찾을 수 없습니다.\n\n" + + "다음을 시도해보세요:\n" + + "- 다른 키워드로 질문해보세요\n" + + "- 관리자에게 관련 문서 등록을 요청하세요"; + } + + String systemPrompt = """ 당신은 전문적인 데이터 분석 및 문서 기반 질의응답 어시스턴트입니다. 【역할】 - 제공된 문서 내용을 깊이 있게 분석하여 답변합니다. - - 데이터를 요약, 비교, 분석하여 인사이트를 제공합니다. + - 이전 대화의 맥락을 이해하고 연속적인 대화를 합니다. - 사용자가 이해하기 쉽게 구조화된 답변을 합니다. 【답변 규칙】 1. 문서에 있는 정보를 최대한 활용하여 상세히 답변하세요. - 2. 숫자, 날짜, 이름 등 구체적인 정보가 있으면 반드시 포함하세요. - 3. 여러 문서의 정보를 종합하여 분석적인 답변을 제공하세요. - 4. 표나 목록 형태로 정리하면 좋은 내용은 구조화하세요. + 2. 이전 대화에서 언급된 주제에 대한 후속 질문은 맥락을 이해하고 답변하세요. + 3. 숫자, 날짜, 이름 등 구체적인 정보가 있으면 반드시 포함하세요. + 4. 여러 문서의 정보를 종합하여 분석적인 답변을 제공하세요. 5. 문서에서 직접 확인되지 않는 내용은 추측하지 마세요. 6. 답변 마지막에 참고한 문서 번호를 명시하세요. @@ -200,42 +257,103 @@ public class ChatService { - 요약 질문: 핵심 포인트를 불릿으로 정리 - 추세/변화 질문: 시간순 또는 단계별로 설명 """; - } - String userPrompt; - if (noContext) { - userPrompt = String.format(""" - [질문] + return callChatApiWithHistory(systemPrompt, question, context, history); + } + + /** + * OpenAI Chat API 호출 (대화 히스토리 포함) + */ + private String callChatApiWithHistory(String systemPrompt, String question, + String context, List history) { + try { + log.info("[Chat] API 호출 시작 - 모델: {}, 히스토리: {}개", chatModel, history.size()); + + // 요청 헤더 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + + // 요청 바디 구성 + Map requestBody = new HashMap<>(); + requestBody.put("model", chatModel); + requestBody.put("temperature", 0.3); + + // 모델에 따라 파라미터 다르게 설정 + if (chatModel.startsWith("gpt-5") || chatModel.startsWith("o3") || chatModel.startsWith("o4")) { + requestBody.put("max_completion_tokens", 2000); + } else { + requestBody.put("max_tokens", 2000); + } + + // 메시지 구성 + List> messages = new ArrayList<>(); + + // 1. 시스템 프롬프트 + Map systemMessage = new HashMap<>(); + systemMessage.put("role", "system"); + systemMessage.put("content", systemPrompt); + messages.add(systemMessage); + + // 2. 이전 대화 히스토리 (맥락 유지용) + for (ChatMessage msg : history) { + Map historyMsg = new HashMap<>(); + historyMsg.put("role", msg.getMsgRole()); + // 이전 답변은 요약해서 전달 (토큰 절약) + String content = msg.getMsgContent(); + if ("assistant".equals(msg.getMsgRole()) && content.length() > 500) { + content = content.substring(0, 500) + "... (이하 생략)"; + } + historyMsg.put("content", content); + messages.add(historyMsg); + } + + // 3. 현재 질문 (문서 컨텍스트 포함) + String userPrompt = String.format(""" %s - 관련 문서를 찾을 수 없었습니다. - 위 안내에 따라 사용자에게 도움이 되는 응답을 해주세요. - """, question); - } else { - userPrompt = String.format(""" + [현재 질문] %s - [질문] - %s - - 위 문서 내용을 분석하여 질문에 상세히 답변해주세요. + 위 문서 내용과 이전 대화 맥락을 고려하여 질문에 상세히 답변해주세요. """, context, question); + + Map userMessage = new HashMap<>(); + userMessage.put("role", "user"); + userMessage.put("content", userPrompt); + messages.add(userMessage); + + requestBody.put("messages", messages); + + // API 호출 + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + log.info("[Chat] API 요청 전송 중... (메시지 {}개)", messages.size()); + ResponseEntity response = restTemplate.exchange( + OPENAI_CHAT_URL, + HttpMethod.POST, + entity, + String.class + ); + + log.info("[Chat] API 응답 수신 - 상태: {}", response.getStatusCode()); + + // 응답 파싱 + if (response.getStatusCode() == HttpStatus.OK) { + JsonNode root = objectMapper.readTree(response.getBody()); + String content = root.path("choices").get(0).path("message").path("content").asText(); + log.info("[Chat] 응답 생성 완료 - 길이: {}", content.length()); + return content; + } + + log.error("[Chat] API 응답 실패 - 상태: {}, 본문: {}", response.getStatusCode(), response.getBody()); + return "응답 생성 중 오류가 발생했습니다."; + + } catch (Exception e) { + log.error("[Chat] API 호출 실패: {}", e.getMessage()); + log.error("[Chat] 상세 오류:", e); + return "죄송합니다. 일시적인 오류가 발생했습니다. 다시 시도해주세요."; } - - List messages = new ArrayList<>(); - messages.add(new ChatMessage("system", systemPrompt)); - messages.add(new ChatMessage("user", userPrompt)); - - ChatCompletionRequest request = ChatCompletionRequest.builder() - .model(chatModel) - .messages(messages) - .temperature(0.3) - .maxTokens(2000) // 더 긴 답변 허용 - .build(); - - ChatCompletionResult result = openAiService.createChatCompletion(request); - - return result.getChoices().get(0).getMessage().getContent(); } private List extractSources(List chunks) { @@ -243,8 +361,14 @@ public class ChatService { .map(row -> SourceInfo.builder() .chunkId(((Number) row[0]).longValue()) .docId(((Number) row[1]).longValue()) - .content(truncate((String) row[3], 150)) + .topicId(row[2] != null ? ((Number) row[2]).longValue() : null) + .content(truncate((String) row[3], 200)) + .chunkIndex(row[4] != null ? ((Number) row[4]).intValue() : 0) .similarity(((Number) row[8]).doubleValue()) + // 문서 정보 추가 + .docName((String) row[9]) // original_name + .fileType((String) row[10]) // file_type + .totalChunks(row[11] != null ? ((Number) row[11]).intValue() : 0) // chunk_count .build()) .collect(Collectors.toList()); } @@ -269,7 +393,13 @@ public class ChatService { public static class SourceInfo { private Long chunkId; private Long docId; + private Long topicId; private String content; + private Integer chunkIndex; private Double similarity; + // 문서 정보 + private String docName; // 원본 파일명 + private String fileType; // 파일 유형 (pdf, docx 등) + private Integer totalChunks; // 전체 청크 수 } } diff --git a/src/main/java/kr/co/ragone/service/ChunkingService.java b/src/main/java/kr/co/ragone/service/ChunkingService.java index f936c3c..0f5f69a 100644 --- a/src/main/java/kr/co/ragone/service/ChunkingService.java +++ b/src/main/java/kr/co/ragone/service/ChunkingService.java @@ -55,7 +55,12 @@ public class ChunkingService { for (String sentence : sentences) { // 현재 청크에 문장 추가 시 크기 초과하면 저장 if (currentChunk.length() + sentence.length() > chunkSize && currentChunk.length() >= MIN_CHUNK_SIZE) { - chunks.add(createChunk(currentChunk.toString().trim(), chunkIndex++)); + // 의미없는 청크는 건너뛰기 + if (!isUselessChunk(currentChunk.toString())) { + chunks.add(createChunk(currentChunk.toString().trim(), chunkIndex++)); + } else { + log.debug("목차/표지 청크 건너뛰기: {}", currentChunk.toString().substring(0, Math.min(50, currentChunk.length()))); + } // 오버랩 처리 String overlap = getOverlapText(currentChunk.toString()); @@ -69,9 +74,9 @@ public class ChunkingService { } // 마지막 청크 저장 - if (currentChunk.length() >= MIN_CHUNK_SIZE) { + if (currentChunk.length() >= MIN_CHUNK_SIZE && !isUselessChunk(currentChunk.toString())) { chunks.add(createChunk(currentChunk.toString().trim(), chunkIndex)); - } else if (currentChunk.length() > 0 && !chunks.isEmpty()) { + } else if (currentChunk.length() > 0 && !chunks.isEmpty() && !isUselessChunk(currentChunk.toString())) { // 너무 짧으면 이전 청크에 병합 ChunkResult lastChunk = chunks.get(chunks.size() - 1); String merged = lastChunk.getContent() + " " + currentChunk.toString().trim(); @@ -96,6 +101,31 @@ public class ChunkingService { .trim(); } + /** + * 의미없는 청크인지 확인 (목차, 표지 등) + */ + private boolean isUselessChunk(String content) { + if (content == null || content.length() < 30) { + return true; + } + + // 목차/표지 페이지 필터링 + String lower = content.toLowerCase(); + if (lower.contains("목차 페이지입니다") || + lower.contains("표지 페이지입니다") || + lower.contains("개정이력 페이지입니다")) { + return true; + } + + // 점선(...)이 너무 많으면 목차 + int dotCount = content.split("\\.\\.\\.").length - 1; + if (dotCount > 3) { + return true; + } + + return false; + } + /** * 문장 단위로 분할 */ diff --git a/src/main/java/kr/co/ragone/service/DocumentIndexingService.java b/src/main/java/kr/co/ragone/service/DocumentIndexingService.java index 907dcd0..e3dc173 100644 --- a/src/main/java/kr/co/ragone/service/DocumentIndexingService.java +++ b/src/main/java/kr/co/ragone/service/DocumentIndexingService.java @@ -34,6 +34,8 @@ public class DocumentIndexingService { private final DocumentParserService documentParserService; private final ChunkingService chunkingService; private final EmbeddingService embeddingService; + private final SmartChunkingService smartChunkingService; + private final VisionService visionService; private final JdbcTemplate jdbcTemplate; @Value("${file.upload-dir:./uploads}") @@ -89,34 +91,61 @@ public class DocumentIndexingService { private void processIndexing(Long docId, TopicInfo topicInfo, MultipartFile file) throws Exception { log.info("인덱싱 시작: docId={}, fileName={}", docId, file.getOriginalFilename()); - // 1. 문서 파싱 - String content = documentParserService.parseDocument(file); + // 문서 정보 조회 + DocInfo docInfo = docInfoRepository.findById(docId) + .orElseThrow(() -> new RuntimeException("문서를 찾을 수 없습니다.")); + + String content; + + // 1. Vision 처리 (PDF + Vision 활성화된 경우) + String fileType = getFileExtension(file.getOriginalFilename()); + if ("pdf".equalsIgnoreCase(fileType) && visionService.isEnabled()) { + log.info("[Vision] PDF Vision 분석 시작..."); + content = visionService.processPdfWithVision(docInfo.getFilePath()); + + if (content == null || content.isBlank()) { + log.warn("[Vision] Vision 분석 실패, 기본 파서로 대체"); + content = documentParserService.parseDocument(file); + } else { + log.info("[Vision] Vision 분석 완료: {} 글자", content.length()); + } + } else { + // 2. 기본 문서 파싱 (Tika) + content = documentParserService.parseDocument(file); + } + if (content == null || content.isBlank()) { throw new RuntimeException("문서 내용이 비어있습니다."); } - // 2. 청킹 + // 3. 청킹 List chunks = chunkingService.chunkText(content); if (chunks.isEmpty()) { throw new RuntimeException("청크 생성 실패"); } log.info("청크 생성 완료: {} chunks", chunks.size()); - // 3. 각 청크에 대해 임베딩 생성 및 저장 - DocInfo docInfo = docInfoRepository.findById(docId) - .orElseThrow(() -> new RuntimeException("문서를 찾을 수 없습니다.")); - - for (ChunkingService.ChunkResult chunk : chunks) { + // 4. 각 청크에 대해 임베딩 생성 및 저장 + for (int i = 0; i < chunks.size(); i++) { + ChunkingService.ChunkResult chunk = chunks.get(i); + // 임베딩 생성 String embeddingVector = embeddingService.createEmbeddingAsString(chunk.getContent()); - // Native Query로 벡터 저장 - saveChunkWithEmbedding(docInfo, topicInfo, chunk, embeddingVector); + // 스마트 청킹: 메타데이터 생성 (활성화된 경우) + SmartChunkingService.ChunkMetadata metadata = null; + if (smartChunkingService.isEnabled()) { + log.info("[SmartChunking] 메타데이터 생성 중... ({}/{})", i + 1, chunks.size()); + metadata = smartChunkingService.generateMetadata(chunk.getContent()); + } + + // Native Query로 벡터 + 메타데이터 저장 + saveChunkWithEmbedding(docInfo, topicInfo, chunk, embeddingVector, metadata); log.debug("청크 저장 완료: index={}", chunk.getIndex()); } - // 4. 문서 상태 업데이트 + // 5. 문서 상태 업데이트 updateDocStatus(docId, "INDEXED", null); updateChunkCount(docId, chunks.size()); @@ -124,16 +153,29 @@ public class DocumentIndexingService { } /** - * 청크 + 벡터 저장 (Native Query 사용) + * 청크 + 벡터 + 메타데이터 저장 (Native Query 사용) */ private void saveChunkWithEmbedding(DocInfo docInfo, TopicInfo topicInfo, - ChunkingService.ChunkResult chunk, String embedding) { + ChunkingService.ChunkResult chunk, String embedding, + SmartChunkingService.ChunkMetadata metadata) { String sql = """ INSERT INTO TB_DOC_CHUNK - (doc_id, topic_id, chunk_content, chunk_embedding, chunk_index, token_count, created_at) - VALUES (?, ?, ?, ?::vector, ?, ?, ?) + (doc_id, topic_id, chunk_content, chunk_embedding, chunk_index, token_count, + chunk_summary, chunk_keywords, chunk_questions, chunk_type, created_at) + VALUES (?, ?, ?, ?::vector, ?, ?, ?, ?, ?, ?, ?) """; + // 메타데이터 처리 + String summary = null; + String keywords = null; + String questions = null; + + if (metadata != null) { + summary = metadata.getSummary(); + keywords = metadata.getKeywords() != null ? String.join(", ", metadata.getKeywords()) : null; + questions = metadata.getQuestions() != null ? toJson(metadata.getQuestions()) : null; + } + jdbcTemplate.update(sql, docInfo.getDocId(), topicInfo.getTopicId(), @@ -141,9 +183,26 @@ public class DocumentIndexingService { embedding, chunk.getIndex(), chunk.getTokenCount(), + summary, + keywords, + questions, + "text", LocalDateTime.now() ); } + + /** + * List를 JSON 문자열로 변환 + */ + private String toJson(java.util.List list) { + if (list == null || list.isEmpty()) return null; + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + return mapper.writeValueAsString(list); + } catch (Exception e) { + return null; + } + } /** * 파일 저장 diff --git a/src/main/java/kr/co/ragone/service/EmbeddingService.java b/src/main/java/kr/co/ragone/service/EmbeddingService.java index f7a9353..6fa6a28 100644 --- a/src/main/java/kr/co/ragone/service/EmbeddingService.java +++ b/src/main/java/kr/co/ragone/service/EmbeddingService.java @@ -26,6 +26,8 @@ public class EmbeddingService { * 텍스트를 임베딩 벡터로 변환 */ public List createEmbedding(String text) { + log.info("[임베딩] 사용 모델: {}", embeddingModel); // 모델 확인 로그 + EmbeddingRequest request = EmbeddingRequest.builder() .model(embeddingModel) .input(Collections.singletonList(text)) @@ -33,7 +35,10 @@ public class EmbeddingService { EmbeddingResult result = openAiService.createEmbeddings(request); - return result.getData().get(0).getEmbedding(); + List embedding = result.getData().get(0).getEmbedding(); + log.info("[임베딩] 생성된 차원: {}", embedding.size()); // 차원 확인 로그 + + return embedding; } /** diff --git a/src/main/java/kr/co/ragone/service/SmartChunkingService.java b/src/main/java/kr/co/ragone/service/SmartChunkingService.java new file mode 100644 index 0000000..b062c76 --- /dev/null +++ b/src/main/java/kr/co/ragone/service/SmartChunkingService.java @@ -0,0 +1,288 @@ +package kr.co.ragone.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmartChunkingService { + + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.model.chat}") + private String chatModel; + + @Value("${rag.smart-chunking.enabled:false}") + private boolean smartChunkingEnabled; + + private static final String OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"; + + /** + * 스마트 청킹 활성화 여부 + */ + public boolean isEnabled() { + return smartChunkingEnabled; + } + + /** + * 청크에 대한 메타데이터 생성 (요약, 키워드, 예상 질문) + */ + public ChunkMetadata generateMetadata(String chunkContent) { + if (!smartChunkingEnabled) { + return null; + } + + try { + String prompt = """ + 다음 텍스트를 분석하여 JSON 형식으로 응답해주세요. + + 【텍스트】 + %s + + 【응답 형식】 + { + "summary": "2-3문장으로 핵심 내용 요약", + "keywords": ["키워드1", "키워드2", "키워드3", "키워드4", "키워드5"], + "questions": ["이 내용에서 나올 수 있는 질문1", "질문2", "질문3"] + } + + 반드시 JSON만 응답하세요. 다른 설명은 포함하지 마세요. + """.formatted(truncateText(chunkContent, 2000)); + + String response = callLLM(prompt); + return parseMetadataResponse(response); + + } catch (Exception e) { + log.warn("메타데이터 생성 실패: {}", e.getMessage()); + return null; + } + } + + /** + * 여러 청크에 대한 메타데이터 일괄 생성 + */ + public List generateMetadataBatch(List chunkContents) { + List results = new ArrayList<>(); + + for (int i = 0; i < chunkContents.size(); i++) { + log.info("[SmartChunking] 메타데이터 생성 중... ({}/{})", i + 1, chunkContents.size()); + ChunkMetadata metadata = generateMetadata(chunkContents.get(i)); + results.add(metadata); + + // API 속도 제한 방지 + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + return results; + } + + /** + * 문서를 의미 단위로 분할 (고급 기능) + */ + public List splitBySemantics(String fullText) { + if (!smartChunkingEnabled || fullText.length() < 1000) { + return null; // 짧은 문서는 기존 방식 사용 + } + + try { + String prompt = """ + 다음 문서를 의미 있는 섹션으로 분할해주세요. + 각 섹션은 하나의 주제나 기능을 다뤄야 합니다. + + 【문서】 + %s + + 【응답 형식】 + JSON 배열로 응답하세요: + [ + { + "title": "섹션 제목", + "content": "섹션 내용 전체", + "type": "text 또는 table 또는 list" + }, + ... + ] + + 반드시 JSON만 응답하세요. + """.formatted(truncateText(fullText, 6000)); + + String response = callLLM(prompt); + return parseSemanticChunks(response); + + } catch (Exception e) { + log.warn("의미 분할 실패, 기존 방식 사용: {}", e.getMessage()); + return null; + } + } + + /** + * LLM 호출 (직접 HTTP 요청 - GPT-4/5 모두 호환) + */ + private String callLLM(String prompt) { + try { + // 요청 헤더 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + + // 요청 바디 구성 + Map requestBody = new HashMap<>(); + requestBody.put("model", chatModel); + requestBody.put("temperature", 0.3); + + // 모델에 따라 파라미터 다르게 설정 + if (chatModel.startsWith("gpt-5") || chatModel.startsWith("o3") || chatModel.startsWith("o4")) { + requestBody.put("max_completion_tokens", 1000); + } else { + requestBody.put("max_tokens", 1000); + } + + // 메시지 구성 + List> messages = new ArrayList<>(); + Map userMessage = new HashMap<>(); + userMessage.put("role", "user"); + userMessage.put("content", prompt); + messages.add(userMessage); + requestBody.put("messages", messages); + + // API 호출 + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.exchange( + OPENAI_CHAT_URL, + HttpMethod.POST, + entity, + String.class + ); + + // 응답 파싱 + if (response.getStatusCode() == HttpStatus.OK) { + JsonNode root = objectMapper.readTree(response.getBody()); + return root.path("choices").get(0).path("message").path("content").asText(); + } + + return null; + + } catch (Exception e) { + log.error("[SmartChunking] LLM 호출 실패: {}", e.getMessage()); + throw new RuntimeException("LLM 호출 실패", e); + } + } + + /** + * 메타데이터 응답 파싱 + */ + private ChunkMetadata parseMetadataResponse(String response) { + try { + // JSON 블록 추출 + String json = extractJson(response); + + var node = objectMapper.readTree(json); + + ChunkMetadata metadata = new ChunkMetadata(); + metadata.setSummary(node.path("summary").asText("")); + + // 키워드 파싱 + List keywords = new ArrayList<>(); + node.path("keywords").forEach(k -> keywords.add(k.asText())); + metadata.setKeywords(keywords); + + // 질문 파싱 + List questions = new ArrayList<>(); + node.path("questions").forEach(q -> questions.add(q.asText())); + metadata.setQuestions(questions); + + return metadata; + } catch (Exception e) { + log.warn("메타데이터 파싱 실패: {}", e.getMessage()); + return null; + } + } + + /** + * 의미 청크 파싱 + */ + private List parseSemanticChunks(String response) { + try { + String json = extractJson(response); + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("의미 청크 파싱 실패: {}", e.getMessage()); + return null; + } + } + + /** + * JSON 추출 (```json ... ``` 블록 처리) + */ + private String extractJson(String text) { + text = text.trim(); + + // ```json ... ``` 블록 추출 + if (text.contains("```json")) { + int start = text.indexOf("```json") + 7; + int end = text.indexOf("```", start); + if (end > start) { + text = text.substring(start, end).trim(); + } + } else if (text.contains("```")) { + int start = text.indexOf("```") + 3; + int end = text.indexOf("```", start); + if (end > start) { + text = text.substring(start, end).trim(); + } + } + + return text; + } + + /** + * 텍스트 길이 제한 + */ + private String truncateText(String text, int maxLength) { + if (text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength) + "..."; + } + + /** + * 청크 메타데이터 DTO + */ + @lombok.Data + public static class ChunkMetadata { + private String summary; + private List keywords; + private List questions; + } + + /** + * 의미 청크 DTO + */ + @lombok.Data + public static class SemanticChunk { + private String title; + private String content; + private String type; + } +} diff --git a/src/main/java/kr/co/ragone/service/VisionService.java b/src/main/java/kr/co/ragone/service/VisionService.java new file mode 100644 index 0000000..1f1a8c4 --- /dev/null +++ b/src/main/java/kr/co/ragone/service/VisionService.java @@ -0,0 +1,212 @@ +package kr.co.ragone.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.util.*; + +@Slf4j +@Service +public class VisionService { + + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + + // RestTemplate with 10 minute timeout + public VisionService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(600000); // 10분 + factory.setReadTimeout(600000); // 10분 + this.restTemplate = new RestTemplate(factory); + } + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${rag.vision.enabled:false}") + private boolean visionEnabled; + + @Value("${rag.vision.model:gpt-4o-mini}") + private String visionModel; + + private static final String OPENAI_VISION_URL = "https://api.openai.com/v1/chat/completions"; + + /** + * Vision 처리 활성화 여부 + */ + public boolean isEnabled() { + return visionEnabled; + } + + /** + * PDF 파일을 Vision 모델로 분석하여 텍스트 추출 + */ + public String processPdfWithVision(String pdfPath) { + if (!visionEnabled) { + log.info("[Vision] 비활성화 상태"); + return null; + } + + StringBuilder allDescriptions = new StringBuilder(); + + try (PDDocument document = PDDocument.load(new File(pdfPath))) { + PDFRenderer renderer = new PDFRenderer(document); + int pageCount = document.getNumberOfPages(); + + log.info("[Vision] PDF 분석 시작: {} 페이지", pageCount); + + for (int i = 0; i < pageCount; i++) { + try { + log.info("[Vision] 페이지 {}/{} 분석 중...", i + 1, pageCount); + + // 페이지를 이미지로 변환 (150 DPI) + BufferedImage image = renderer.renderImageWithDPI(i, 150, ImageType.RGB); + + // 이미지를 Base64로 인코딩 + String base64Image = encodeImageToBase64(image); + + // Vision API 호출 + String description = callVisionApi(base64Image, i + 1, pageCount); + + if (description != null && !description.isEmpty()) { + allDescriptions.append("\n\n=== 페이지 ").append(i + 1).append(" ===\n"); + allDescriptions.append(description); + } + + // API 속도 제한 방지 (1초 대기) + Thread.sleep(1000); + + } catch (Exception e) { + log.warn("[Vision] 페이지 {} 분석 실패: {}", i + 1, e.getMessage()); + } + } + + log.info("[Vision] PDF 분석 완료"); + + } catch (Exception e) { + log.error("[Vision] PDF 처리 실패: {}", e.getMessage()); + return null; + } + + return allDescriptions.toString(); + } + + /** + * OpenAI Vision API 직접 호출 + */ + private String callVisionApi(String base64Image, int pageNum, int totalPages) { + try { + // 요청 헤더 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + + // 프롬프트 구성 + String prompt = """ + 이 문서 페이지(페이지 %d/%d)의 내용을 분석해주세요. + + 【분석 지침】 + 1. 모든 텍스트 내용을 정확하게 추출하세요 + 2. 표(테이블)가 있으면 마크다운 표 형식으로 변환하세요 + 3. 요구사항 ID, 제목, 내용이 있으면 구조화해서 정리하세요 + 4. 다이어그램/차트가 있으면 설명을 추가하세요 + 5. 중요한 항목은 놓치지 마세요 + + 【중요: 제외할 내용】 + - 목차 페이지는 간단히 "목차 페이지입니다"라고만 응답하세요 + - 페이지 번호와 점선(...)만 있는 내용은 제외하세요 + - 표지, 개정이력 등 의미없는 페이지는 간단히 "표지/개정이력 페이지입니다"라고만 응답 + + 【출력 형식】 + - 한국어로 응답 + - 원본 구조를 최대한 유지 + - 표는 마크다운 형식 사용 + """.formatted(pageNum, totalPages); + + // 요청 바디 구성 + Map requestBody = new HashMap<>(); + requestBody.put("model", visionModel); + + // 모델에 따라 파라미터 다르게 설정 + if (visionModel.startsWith("gpt-5") || visionModel.startsWith("o3") || visionModel.startsWith("o4")) { + requestBody.put("max_completion_tokens", 4096); + } else { + requestBody.put("max_tokens", 4096); + } + + // 메시지 구성 (Vision API 형식) + List> messages = new ArrayList<>(); + Map userMessage = new HashMap<>(); + userMessage.put("role", "user"); + + // content 배열 (텍스트 + 이미지) + List> content = new ArrayList<>(); + + // 텍스트 부분 + Map textContent = new HashMap<>(); + textContent.put("type", "text"); + textContent.put("text", prompt); + content.add(textContent); + + // 이미지 부분 + Map imageContent = new HashMap<>(); + imageContent.put("type", "image_url"); + Map imageUrl = new HashMap<>(); + imageUrl.put("url", "data:image/png;base64," + base64Image); + imageUrl.put("detail", "high"); // high quality 분석 + imageContent.put("image_url", imageUrl); + content.add(imageContent); + + userMessage.put("content", content); + messages.add(userMessage); + requestBody.put("messages", messages); + + // API 호출 + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.exchange( + OPENAI_VISION_URL, + HttpMethod.POST, + entity, + String.class + ); + + // 응답 파싱 + if (response.getStatusCode() == HttpStatus.OK) { + JsonNode root = objectMapper.readTree(response.getBody()); + String result = root.path("choices").get(0).path("message").path("content").asText(); + log.debug("[Vision] 페이지 {} 분석 결과: {} 글자", pageNum, result.length()); + return result; + } + + } catch (Exception e) { + log.error("[Vision] API 호출 실패: {}", e.getMessage()); + } + + return null; + } + + /** + * 이미지를 Base64로 인코딩 + */ + private String encodeImageToBase64(BufferedImage image) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + byte[] imageBytes = baos.toByteArray(); + return Base64.getEncoder().encodeToString(imageBytes); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f06e850..0332fcc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,54 +1,101 @@ -server: - port: 8080 +# ===================================================== +# RAGone 백엔드 설정 파일 +# ===================================================== +server: + port: 8080 # 서버 포트 + servlet: + session: + timeout: 30m # 세션 타임아웃 + tomcat: + connection-timeout: 600000 # 커넥션 타임아웃 10분 (ms) + keep-alive-timeout: 600000 # Keep-alive 10분 + +# ----------------------------------------------------- +# Spring 기본 설정 +# ----------------------------------------------------- spring: application: name: ragone profiles: - active: local + active: local # 활성 프로파일 (local, dev, prod) + # 데이터베이스 연결 설정 (PostgreSQL + pgvector) datasource: url: jdbc:postgresql://172.25.0.79:5432/turbosoft_rag_db username: turbosoft password: xjqhthvmxm123 driver-class-name: org.postgresql.Driver + # JPA/Hibernate 설정 jpa: hibernate: - ddl-auto: validate - show-sql: true + ddl-auto: validate # 스키마 검증만 (create, update, validate, none) + show-sql: true # SQL 로그 출력 properties: hibernate: - format_sql: true + format_sql: true # SQL 포맷팅 dialect: org.hibernate.dialect.PostgreSQLDialect + # 파일 업로드 제한 servlet: multipart: - max-file-size: 50MB - max-request-size: 50MB + max-file-size: 50MB # 단일 파일 최대 크기 + max-request-size: 50MB # 요청 전체 최대 크기 -# OpenAI 설정 +# ----------------------------------------------------- +# OpenAI API 설정 +# ----------------------------------------------------- openai: - api-key: ${OPENAI_API_KEY:your-api-key-here} + api-key: ${OPENAI_API_KEY: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} model: - embedding: text-embedding-3-small - chat: gpt-4o-mini + embedding: text-embedding-3-small # 임베딩 모델 (텍스트 → 벡터 변환) + chat: gpt-4o # 채팅 모델 - 일단 gpt-4o로 테스트 -# RAG 설정 +# ----------------------------------------------------- +# RAG (Retrieval-Augmented Generation) 설정 +# ----------------------------------------------------- rag: + # 문서 청킹 설정 chunk: - size: 1000 - overlap: 100 + size: 2000 # 청크 크기 (토큰 수) - 클수록 문맥 많이 포함 + overlap: 100 # 청크 간 오버랩 - 문맥 끊김 방지 + + # 벡터 검색 설정 retrieval: - top-k: 10 - similarity-threshold: 0.3 # 더 낮춰서 검색 범위 확대 + top-k: 10 # 검색할 최대 청크 개수 + similarity-threshold: 0.3 # 유사도 임계값 (0.0 ~ 1.0) + # - 1.0: 완전 일치만 + # - 0.5: 중간 정도 관련 + # - 0.3: 느슨한 검색 (더 많은 결과) + # - 0.1: 매우 느슨 (테스트용) + # 스마트 청킹 설정 (LLM 기반 메타데이터 생성) + smart-chunking: + enabled: true # 스마트 청킹 활성화 (true/false) + # - true: 각 청크에 요약/키워드/예상질문 생성 + # - false: 기존 방식 (단순 청킹만) + # 주의: 활성화 시 API 비용 증가 + # Vision 처리 설정 (PDF 이미지/표 분석) + vision: + enabled: true # Vision 처리 활성화 (true/false) + # - true: PDF 페이지를 이미지로 변환 후 분석 + # - false: 텍스트만 추출 + # 주의: 활성화 시 API 비용 크게 증가 + model: gpt-4o # Vision 모델 - gpt-4o 사용 + +# ----------------------------------------------------- # 파일 저장 경로 +# ----------------------------------------------------- file: - upload-dir: ./uploads + upload-dir: ./uploads # 업로드 파일 저장 디렉토리 +# ----------------------------------------------------- +# 로깅 설정 +# ----------------------------------------------------- logging: level: - kr.co.ragone: DEBUG - org.hibernate.SQL: DEBUG + kr.co.ragone: DEBUG # 애플리케이션 로그 레벨 + org.hibernate.SQL: DEBUG # SQL 쿼리 로그 + # org.hibernate.type.descriptor.sql: TRACE # SQL 파라미터 값 (필요시 활성화)