변경2
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 반환
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 = """
|
||||
당신은 친절한 문서 기반 질의응답 어시스턴트입니다.
|
||||
현재 검색된 관련 문서가 없습니다.
|
||||
return "죄송합니다. 질문과 관련된 문서를 찾을 수 없습니다.\n\n" +
|
||||
"다음을 시도해보세요:\n" +
|
||||
"- 다른 키워드로 질문해보세요\n" +
|
||||
"- 관리자에게 관련 문서 등록을 요청하세요";
|
||||
}
|
||||
|
||||
사용자에게 다음을 안내해주세요:
|
||||
1. 해당 질문과 관련된 문서가 시스템에 등록되어 있지 않을 수 있습니다.
|
||||
2. 더 구체적인 키워드로 질문하면 도움이 될 수 있습니다.
|
||||
3. 관리자에게 관련 문서 등록을 요청할 수 있습니다.
|
||||
|
||||
단, 일반적인 상식이나 공개된 정보로 답변 가능한 경우 도움을 드릴 수 있습니다.
|
||||
""";
|
||||
} else {
|
||||
systemPrompt = """
|
||||
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; // 전체 청크 수
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문장 단위로 분할
|
||||
*/
|
||||
|
||||
@@ -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("문서를 찾을 수 없습니다."));
|
||||
// 4. 각 청크에 대해 임베딩 생성 및 저장
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
ChunkingService.ChunkResult chunk = chunks.get(i);
|
||||
|
||||
for (ChunkingService.ChunkResult chunk : chunks) {
|
||||
// 임베딩 생성
|
||||
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,10 +183,27 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 저장
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
288
src/main/java/kr/co/ragone/service/SmartChunkingService.java
Normal file
288
src/main/java/kr/co/ragone/service/SmartChunkingService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
212
src/main/java/kr/co/ragone/service/VisionService.java
Normal file
212
src/main/java/kr/co/ragone/service/VisionService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 파라미터 값 (필요시 활성화)
|
||||
|
||||
Reference in New Issue
Block a user