This commit is contained in:
2025-12-12 02:54:34 +09:00
parent 616a2894e0
commit 7d9069de1a
12 changed files with 994 additions and 117 deletions

View File

@@ -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'

View File

@@ -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<List<ChunkResponse>> getChunks(@PathVariable Long docId) {
List<DocChunk> chunks = docChunkRepository.findByDocInfo_DocId(docId);
List<ChunkResponse> 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 반환
*/

View File

@@ -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
}

View File

@@ -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<ChatMessage, Long> {
List<ChatMessage> 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<ChatMessage> findRecentMessages(
@Param("sessionId") Long sessionId,
@Param("limit") int limit
);
}

View File

@@ -16,13 +16,20 @@ public interface DocChunkRepository extends JpaRepository<DocChunk, Long> {
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<DocChunk, Long> {
);
/**
* 벡터 유사도 검색 (특정 주제들)
* 벡터 유사도 검색 (특정 주제 - 단일) - 문서 정보 포함
*/
@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<Object[]> 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<Object[]> findSimilarChunksByTopics(
@Param("embedding") String embedding,
@Param("topicIds") Long[] topicIds,
@Param("topicIds") List<Long> topicIds,
@Param("threshold") double threshold,
@Param("limit") int limit
);

View File

@@ -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<Long> topicIds, String sessionKey) {
// 1. 세션 조회 또는 생성
ChatSession session = getOrCreateSession(sessionKey, question);
// 2. 사용자 메시지 저장
// 2. 이전 대화 히스토리 조회
List<ChatMessage> 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<Object[]> 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<SourceInfo> 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<ChatMessage> getConversationHistory(ChatSession session) {
if (session.getSessionId() == null) {
return Collections.emptyList();
}
return chatMessageRepository.findRecentMessages(session.getSessionId(), MAX_HISTORY_MESSAGES);
}
/**
* 맥락 기반 검색 쿼리 구성
* - 짧은 질문(예: "기간은?")인 경우 이전 대화의 키워드를 추가
*/
private String buildSearchQuery(String question, List<ChatMessage> 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<Long> topicIds, List<SourceInfo> 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<ChatMessage> 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<ChatMessage> history) {
try {
log.info("[Chat] API 호출 시작 - 모델: {}, 히스토리: {}개", chatModel, history.size());
// 요청 헤더
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(apiKey);
// 요청 바디 구성
Map<String, Object> 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<Map<String, Object>> messages = new ArrayList<>();
// 1. 시스템 프롬프트
Map<String, Object> systemMessage = new HashMap<>();
systemMessage.put("role", "system");
systemMessage.put("content", systemPrompt);
messages.add(systemMessage);
// 2. 이전 대화 히스토리 (맥락 유지용)
for (ChatMessage msg : history) {
Map<String, Object> 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<String, Object> userMessage = new HashMap<>();
userMessage.put("role", "user");
userMessage.put("content", userPrompt);
messages.add(userMessage);
requestBody.put("messages", messages);
// API 호출
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
log.info("[Chat] API 요청 전송 중... (메시지 {}개)", messages.size());
ResponseEntity<String> 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<ChatMessage> 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<SourceInfo> extractSources(List<Object[]> 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; // 전체 청크 수
}
}

View File

@@ -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;
}
/**
* 문장 단위로 분할
*/

View File

@@ -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<ChunkingService.ChunkResult> 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<String> 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;
}
}
/**
* 파일 저장

View File

@@ -26,6 +26,8 @@ public class EmbeddingService {
* 텍스트를 임베딩 벡터로 변환
*/
public List<Double> 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<Double> embedding = result.getData().get(0).getEmbedding();
log.info("[임베딩] 생성된 차원: {}", embedding.size()); // 차원 확인 로그
return embedding;
}
/**

View File

@@ -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<ChunkMetadata> generateMetadataBatch(List<String> chunkContents) {
List<ChunkMetadata> 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<SemanticChunk> 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<String, Object> 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<Map<String, Object>> messages = new ArrayList<>();
Map<String, Object> userMessage = new HashMap<>();
userMessage.put("role", "user");
userMessage.put("content", prompt);
messages.add(userMessage);
requestBody.put("messages", messages);
// API 호출
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> 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<String> keywords = new ArrayList<>();
node.path("keywords").forEach(k -> keywords.add(k.asText()));
metadata.setKeywords(keywords);
// 질문 파싱
List<String> 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<SemanticChunk> parseSemanticChunks(String response) {
try {
String json = extractJson(response);
return objectMapper.readValue(json, new TypeReference<List<SemanticChunk>>() {});
} 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<String> keywords;
private List<String> questions;
}
/**
* 의미 청크 DTO
*/
@lombok.Data
public static class SemanticChunk {
private String title;
private String content;
private String type;
}
}

View File

@@ -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<String, Object> 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<Map<String, Object>> messages = new ArrayList<>();
Map<String, Object> userMessage = new HashMap<>();
userMessage.put("role", "user");
// content 배열 (텍스트 + 이미지)
List<Map<String, Object>> content = new ArrayList<>();
// 텍스트 부분
Map<String, Object> textContent = new HashMap<>();
textContent.put("type", "text");
textContent.put("text", prompt);
content.add(textContent);
// 이미지 부분
Map<String, Object> imageContent = new HashMap<>();
imageContent.put("type", "image_url");
Map<String, String> 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<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> 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);
}
}

View File

@@ -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 파라미터 값 (필요시 활성화)