변경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-core:2.9.1'
|
||||||
implementation 'org.apache.tika:tika-parsers-standard-package: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'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.ragone.controller;
|
package kr.co.ragone.controller;
|
||||||
|
|
||||||
|
import kr.co.ragone.domain.DocChunk;
|
||||||
import kr.co.ragone.domain.DocInfo;
|
import kr.co.ragone.domain.DocInfo;
|
||||||
|
import kr.co.ragone.repository.DocChunkRepository;
|
||||||
import kr.co.ragone.repository.DocInfoRepository;
|
import kr.co.ragone.repository.DocInfoRepository;
|
||||||
import kr.co.ragone.service.DocumentIndexingService;
|
import kr.co.ragone.service.DocumentIndexingService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -27,6 +29,7 @@ public class DocumentController {
|
|||||||
|
|
||||||
private final DocumentIndexingService documentIndexingService;
|
private final DocumentIndexingService documentIndexingService;
|
||||||
private final DocInfoRepository docInfoRepository;
|
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 반환
|
* 파일 확장자에 따른 Content-Type 반환
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -49,4 +49,18 @@ public class DocChunk {
|
|||||||
@Column(name = "created_at")
|
@Column(name = "created_at")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private LocalDateTime createdAt = LocalDateTime.now();
|
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 kr.co.ragone.domain.ChatMessage;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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 org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -10,4 +12,20 @@ import java.util.List;
|
|||||||
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
|
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
|
||||||
|
|
||||||
List<ChatMessage> findByChatSession_SessionIdOrderByCreatedAtAsc(Long sessionId);
|
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);
|
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 = """
|
@Query(value = """
|
||||||
SELECT c.chunk_id, c.doc_id, c.topic_id, c.chunk_content,
|
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,
|
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
|
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
|
WHERE 1 - (c.chunk_embedding <=> cast(:embedding as vector)) > :threshold
|
||||||
ORDER BY c.chunk_embedding <=> cast(:embedding as vector)
|
ORDER BY c.chunk_embedding <=> cast(:embedding as vector)
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
@@ -34,21 +41,45 @@ public interface DocChunkRepository extends JpaRepository<DocChunk, Long> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 벡터 유사도 검색 (특정 주제들)
|
* 벡터 유사도 검색 (특정 주제 - 단일) - 문서 정보 포함
|
||||||
*/
|
*/
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT c.chunk_id, c.doc_id, c.topic_id, c.chunk_content,
|
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,
|
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
|
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
|
AND 1 - (c.chunk_embedding <=> cast(:embedding as vector)) > :threshold
|
||||||
ORDER BY c.chunk_embedding <=> cast(:embedding as vector)
|
ORDER BY c.chunk_embedding <=> cast(:embedding as vector)
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
List<Object[]> findSimilarChunksByTopics(
|
List<Object[]> findSimilarChunksByTopics(
|
||||||
@Param("embedding") String embedding,
|
@Param("embedding") String embedding,
|
||||||
@Param("topicIds") Long[] topicIds,
|
@Param("topicIds") List<Long> topicIds,
|
||||||
@Param("threshold") double threshold,
|
@Param("threshold") double threshold,
|
||||||
@Param("limit") int limit
|
@Param("limit") int limit
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package kr.co.ragone.service;
|
package kr.co.ragone.service;
|
||||||
|
|
||||||
import com.theokanning.openai.completion.chat.ChatCompletionRequest;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.theokanning.openai.completion.chat.ChatCompletionResult;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.theokanning.openai.completion.chat.ChatMessage;
|
import kr.co.ragone.domain.ChatMessage;
|
||||||
import com.theokanning.openai.service.OpenAiService;
|
|
||||||
import kr.co.ragone.domain.ChatSession;
|
import kr.co.ragone.domain.ChatSession;
|
||||||
import kr.co.ragone.repository.ChatMessageRepository;
|
import kr.co.ragone.repository.ChatMessageRepository;
|
||||||
import kr.co.ragone.repository.ChatSessionRepository;
|
import kr.co.ragone.repository.ChatSessionRepository;
|
||||||
@@ -11,13 +10,13 @@ import kr.co.ragone.repository.DocChunkRepository;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.*;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -25,11 +24,15 @@ import java.util.stream.Collectors;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ChatService {
|
public class ChatService {
|
||||||
|
|
||||||
private final OpenAiService openAiService;
|
|
||||||
private final EmbeddingService embeddingService;
|
private final EmbeddingService embeddingService;
|
||||||
private final DocChunkRepository docChunkRepository;
|
private final DocChunkRepository docChunkRepository;
|
||||||
private final ChatSessionRepository chatSessionRepository;
|
private final ChatSessionRepository chatSessionRepository;
|
||||||
private final ChatMessageRepository chatMessageRepository;
|
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}")
|
@Value("${openai.model.chat}")
|
||||||
private String chatModel;
|
private String chatModel;
|
||||||
@@ -40,24 +43,37 @@ public class ChatService {
|
|||||||
@Value("${rag.retrieval.similarity-threshold}")
|
@Value("${rag.retrieval.similarity-threshold}")
|
||||||
private double similarityThreshold;
|
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
|
@Transactional
|
||||||
public RagResponse ask(String question, List<Long> topicIds, String sessionKey) {
|
public RagResponse ask(String question, List<Long> topicIds, String sessionKey) {
|
||||||
// 1. 세션 조회 또는 생성
|
// 1. 세션 조회 또는 생성
|
||||||
ChatSession session = getOrCreateSession(sessionKey, question);
|
ChatSession session = getOrCreateSession(sessionKey, question);
|
||||||
|
|
||||||
// 2. 사용자 메시지 저장
|
// 2. 이전 대화 히스토리 조회
|
||||||
|
List<ChatMessage> history = getConversationHistory(session);
|
||||||
|
log.info("[RAG] 이전 대화 {}개 로드됨", history.size());
|
||||||
|
|
||||||
|
// 3. 사용자 메시지 저장
|
||||||
saveMessage(session, "user", question, topicIds, null);
|
saveMessage(session, "user", question, topicIds, null);
|
||||||
|
|
||||||
// 3. 질문 임베딩
|
// 4. 검색 쿼리 구성 (맥락 기반)
|
||||||
String questionEmbedding = embeddingService.createEmbeddingAsString(question);
|
String searchQuery = buildSearchQuery(question, history);
|
||||||
log.info("[RAG] Question: {}", question);
|
log.info("[RAG] ============ START ============");
|
||||||
|
log.info("[RAG] 원본 질문: {}", question);
|
||||||
|
log.info("[RAG] 검색 쿼리: {}", searchQuery);
|
||||||
log.info("[RAG] TopicIds: {}", topicIds);
|
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);
|
log.info("[RAG] Threshold: {}, TopK: {}", similarityThreshold, topK);
|
||||||
|
|
||||||
// 4. 유사 문서 검색
|
// 6. 유사 문서 검색
|
||||||
List<Object[]> chunks;
|
List<Object[]> chunks;
|
||||||
if (topicIds == null || topicIds.isEmpty()) {
|
if (topicIds == null || topicIds.isEmpty()) {
|
||||||
log.info("[RAG] Searching ALL topics");
|
log.info("[RAG] Searching ALL topics");
|
||||||
@@ -66,7 +82,7 @@ public class ChatService {
|
|||||||
} else {
|
} else {
|
||||||
log.info("[RAG] Searching specific topics: {}", topicIds);
|
log.info("[RAG] Searching specific topics: {}", topicIds);
|
||||||
chunks = docChunkRepository.findSimilarChunksByTopics(
|
chunks = docChunkRepository.findSimilarChunksByTopics(
|
||||||
questionEmbedding, topicIds.toArray(new Long[0]),
|
questionEmbedding, topicIds,
|
||||||
similarityThreshold, topK);
|
similarityThreshold, topK);
|
||||||
}
|
}
|
||||||
log.info("[RAG] Found {} relevant chunks", chunks.size());
|
log.info("[RAG] Found {} relevant chunks", chunks.size());
|
||||||
@@ -81,17 +97,17 @@ public class ChatService {
|
|||||||
content.substring(0, Math.min(100, content.length())));
|
content.substring(0, Math.min(100, content.length())));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 컨텍스트 구성
|
// 7. 컨텍스트 구성
|
||||||
String context = buildContext(chunks);
|
String context = buildContext(chunks);
|
||||||
|
|
||||||
// 6. 프롬프트 구성 및 GPT 호출
|
// 8. 프롬프트 구성 및 GPT 호출 (대화 히스토리 포함)
|
||||||
String answer = generateAnswer(question, context, chunks.isEmpty());
|
String answer = generateAnswerWithHistory(question, context, history, chunks.isEmpty());
|
||||||
|
|
||||||
// 7. AI 응답 메시지 저장
|
// 9. AI 응답 메시지 저장
|
||||||
List<SourceInfo> sources = extractSources(chunks);
|
List<SourceInfo> sources = extractSources(chunks);
|
||||||
saveMessage(session, "assistant", answer, topicIds, sources);
|
saveMessage(session, "assistant", answer, topicIds, sources);
|
||||||
|
|
||||||
// 8. 응답 구성
|
// 10. 응답 구성
|
||||||
return RagResponse.builder()
|
return RagResponse.builder()
|
||||||
.sessionKey(session.getSessionKey())
|
.sessionKey(session.getSessionKey())
|
||||||
.answer(answer)
|
.answer(answer)
|
||||||
@@ -99,6 +115,46 @@ public class ChatService {
|
|||||||
.build();
|
.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,
|
private void saveMessage(ChatSession session, String role, String content,
|
||||||
List<Long> topicIds, List<SourceInfo> sources) {
|
List<Long> topicIds, List<SourceInfo> sources) {
|
||||||
kr.co.ragone.domain.ChatMessage message = kr.co.ragone.domain.ChatMessage.builder()
|
ChatMessage message = ChatMessage.builder()
|
||||||
.chatSession(session)
|
.chatSession(session)
|
||||||
.msgRole(role)
|
.msgRole(role)
|
||||||
.msgContent(content)
|
.msgContent(content)
|
||||||
@@ -153,8 +209,12 @@ public class ChatService {
|
|||||||
Object[] row = chunks.get(i);
|
Object[] row = chunks.get(i);
|
||||||
String content = (String) row[3]; // chunk_content
|
String content = (String) row[3]; // chunk_content
|
||||||
Double similarity = ((Number) row[8]).doubleValue(); // similarity
|
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("─".repeat(40)).append("\n");
|
||||||
sb.append(content.trim());
|
sb.append(content.trim());
|
||||||
sb.append("\n\n");
|
sb.append("\n\n");
|
||||||
@@ -162,35 +222,32 @@ public class ChatService {
|
|||||||
return sb.toString();
|
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) {
|
if (noContext) {
|
||||||
systemPrompt = """
|
return "죄송합니다. 질문과 관련된 문서를 찾을 수 없습니다.\n\n" +
|
||||||
당신은 친절한 문서 기반 질의응답 어시스턴트입니다.
|
"다음을 시도해보세요:\n" +
|
||||||
현재 검색된 관련 문서가 없습니다.
|
"- 다른 키워드로 질문해보세요\n" +
|
||||||
|
"- 관리자에게 관련 문서 등록을 요청하세요";
|
||||||
|
}
|
||||||
|
|
||||||
사용자에게 다음을 안내해주세요:
|
String systemPrompt = """
|
||||||
1. 해당 질문과 관련된 문서가 시스템에 등록되어 있지 않을 수 있습니다.
|
|
||||||
2. 더 구체적인 키워드로 질문하면 도움이 될 수 있습니다.
|
|
||||||
3. 관리자에게 관련 문서 등록을 요청할 수 있습니다.
|
|
||||||
|
|
||||||
단, 일반적인 상식이나 공개된 정보로 답변 가능한 경우 도움을 드릴 수 있습니다.
|
|
||||||
""";
|
|
||||||
} else {
|
|
||||||
systemPrompt = """
|
|
||||||
당신은 전문적인 데이터 분석 및 문서 기반 질의응답 어시스턴트입니다.
|
당신은 전문적인 데이터 분석 및 문서 기반 질의응답 어시스턴트입니다.
|
||||||
|
|
||||||
【역할】
|
【역할】
|
||||||
- 제공된 문서 내용을 깊이 있게 분석하여 답변합니다.
|
- 제공된 문서 내용을 깊이 있게 분석하여 답변합니다.
|
||||||
- 데이터를 요약, 비교, 분석하여 인사이트를 제공합니다.
|
- 이전 대화의 맥락을 이해하고 연속적인 대화를 합니다.
|
||||||
- 사용자가 이해하기 쉽게 구조화된 답변을 합니다.
|
- 사용자가 이해하기 쉽게 구조화된 답변을 합니다.
|
||||||
|
|
||||||
【답변 규칙】
|
【답변 규칙】
|
||||||
1. 문서에 있는 정보를 최대한 활용하여 상세히 답변하세요.
|
1. 문서에 있는 정보를 최대한 활용하여 상세히 답변하세요.
|
||||||
2. 숫자, 날짜, 이름 등 구체적인 정보가 있으면 반드시 포함하세요.
|
2. 이전 대화에서 언급된 주제에 대한 후속 질문은 맥락을 이해하고 답변하세요.
|
||||||
3. 여러 문서의 정보를 종합하여 분석적인 답변을 제공하세요.
|
3. 숫자, 날짜, 이름 등 구체적인 정보가 있으면 반드시 포함하세요.
|
||||||
4. 표나 목록 형태로 정리하면 좋은 내용은 구조화하세요.
|
4. 여러 문서의 정보를 종합하여 분석적인 답변을 제공하세요.
|
||||||
5. 문서에서 직접 확인되지 않는 내용은 추측하지 마세요.
|
5. 문서에서 직접 확인되지 않는 내용은 추측하지 마세요.
|
||||||
6. 답변 마지막에 참고한 문서 번호를 명시하세요.
|
6. 답변 마지막에 참고한 문서 번호를 명시하세요.
|
||||||
|
|
||||||
@@ -200,42 +257,103 @@ public class ChatService {
|
|||||||
- 요약 질문: 핵심 포인트를 불릿으로 정리
|
- 요약 질문: 핵심 포인트를 불릿으로 정리
|
||||||
- 추세/변화 질문: 시간순 또는 단계별로 설명
|
- 추세/변화 질문: 시간순 또는 단계별로 설명
|
||||||
""";
|
""";
|
||||||
}
|
|
||||||
|
|
||||||
String userPrompt;
|
return callChatApiWithHistory(systemPrompt, question, context, history);
|
||||||
if (noContext) {
|
}
|
||||||
userPrompt = String.format("""
|
|
||||||
[질문]
|
/**
|
||||||
|
* 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
|
%s
|
||||||
|
|
||||||
관련 문서를 찾을 수 없었습니다.
|
[현재 질문]
|
||||||
위 안내에 따라 사용자에게 도움이 되는 응답을 해주세요.
|
|
||||||
""", question);
|
|
||||||
} else {
|
|
||||||
userPrompt = String.format("""
|
|
||||||
%s
|
%s
|
||||||
|
|
||||||
[질문]
|
위 문서 내용과 이전 대화 맥락을 고려하여 질문에 상세히 답변해주세요.
|
||||||
%s
|
|
||||||
|
|
||||||
위 문서 내용을 분석하여 질문에 상세히 답변해주세요.
|
|
||||||
""", context, question);
|
""", 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) {
|
private List<SourceInfo> extractSources(List<Object[]> chunks) {
|
||||||
@@ -243,8 +361,14 @@ public class ChatService {
|
|||||||
.map(row -> SourceInfo.builder()
|
.map(row -> SourceInfo.builder()
|
||||||
.chunkId(((Number) row[0]).longValue())
|
.chunkId(((Number) row[0]).longValue())
|
||||||
.docId(((Number) row[1]).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())
|
.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())
|
.build())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
@@ -269,7 +393,13 @@ public class ChatService {
|
|||||||
public static class SourceInfo {
|
public static class SourceInfo {
|
||||||
private Long chunkId;
|
private Long chunkId;
|
||||||
private Long docId;
|
private Long docId;
|
||||||
|
private Long topicId;
|
||||||
private String content;
|
private String content;
|
||||||
|
private Integer chunkIndex;
|
||||||
private Double similarity;
|
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) {
|
for (String sentence : sentences) {
|
||||||
// 현재 청크에 문장 추가 시 크기 초과하면 저장
|
// 현재 청크에 문장 추가 시 크기 초과하면 저장
|
||||||
if (currentChunk.length() + sentence.length() > chunkSize && currentChunk.length() >= MIN_CHUNK_SIZE) {
|
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());
|
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));
|
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);
|
ChunkResult lastChunk = chunks.get(chunks.size() - 1);
|
||||||
String merged = lastChunk.getContent() + " " + currentChunk.toString().trim();
|
String merged = lastChunk.getContent() + " " + currentChunk.toString().trim();
|
||||||
@@ -96,6 +101,31 @@ public class ChunkingService {
|
|||||||
.trim();
|
.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 DocumentParserService documentParserService;
|
||||||
private final ChunkingService chunkingService;
|
private final ChunkingService chunkingService;
|
||||||
private final EmbeddingService embeddingService;
|
private final EmbeddingService embeddingService;
|
||||||
|
private final SmartChunkingService smartChunkingService;
|
||||||
|
private final VisionService visionService;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Value("${file.upload-dir:./uploads}")
|
@Value("${file.upload-dir:./uploads}")
|
||||||
@@ -89,34 +91,61 @@ public class DocumentIndexingService {
|
|||||||
private void processIndexing(Long docId, TopicInfo topicInfo, MultipartFile file) throws Exception {
|
private void processIndexing(Long docId, TopicInfo topicInfo, MultipartFile file) throws Exception {
|
||||||
log.info("인덱싱 시작: docId={}, fileName={}", docId, file.getOriginalFilename());
|
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()) {
|
if (content == null || content.isBlank()) {
|
||||||
throw new RuntimeException("문서 내용이 비어있습니다.");
|
throw new RuntimeException("문서 내용이 비어있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 청킹
|
// 3. 청킹
|
||||||
List<ChunkingService.ChunkResult> chunks = chunkingService.chunkText(content);
|
List<ChunkingService.ChunkResult> chunks = chunkingService.chunkText(content);
|
||||||
if (chunks.isEmpty()) {
|
if (chunks.isEmpty()) {
|
||||||
throw new RuntimeException("청크 생성 실패");
|
throw new RuntimeException("청크 생성 실패");
|
||||||
}
|
}
|
||||||
log.info("청크 생성 완료: {} chunks", chunks.size());
|
log.info("청크 생성 완료: {} chunks", chunks.size());
|
||||||
|
|
||||||
// 3. 각 청크에 대해 임베딩 생성 및 저장
|
// 4. 각 청크에 대해 임베딩 생성 및 저장
|
||||||
DocInfo docInfo = docInfoRepository.findById(docId)
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
.orElseThrow(() -> new RuntimeException("문서를 찾을 수 없습니다."));
|
ChunkingService.ChunkResult chunk = chunks.get(i);
|
||||||
|
|
||||||
for (ChunkingService.ChunkResult chunk : chunks) {
|
|
||||||
// 임베딩 생성
|
// 임베딩 생성
|
||||||
String embeddingVector = embeddingService.createEmbeddingAsString(chunk.getContent());
|
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());
|
log.debug("청크 저장 완료: index={}", chunk.getIndex());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 문서 상태 업데이트
|
// 5. 문서 상태 업데이트
|
||||||
updateDocStatus(docId, "INDEXED", null);
|
updateDocStatus(docId, "INDEXED", null);
|
||||||
updateChunkCount(docId, chunks.size());
|
updateChunkCount(docId, chunks.size());
|
||||||
|
|
||||||
@@ -124,16 +153,29 @@ public class DocumentIndexingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 청크 + 벡터 저장 (Native Query 사용)
|
* 청크 + 벡터 + 메타데이터 저장 (Native Query 사용)
|
||||||
*/
|
*/
|
||||||
private void saveChunkWithEmbedding(DocInfo docInfo, TopicInfo topicInfo,
|
private void saveChunkWithEmbedding(DocInfo docInfo, TopicInfo topicInfo,
|
||||||
ChunkingService.ChunkResult chunk, String embedding) {
|
ChunkingService.ChunkResult chunk, String embedding,
|
||||||
|
SmartChunkingService.ChunkMetadata metadata) {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO TB_DOC_CHUNK
|
INSERT INTO TB_DOC_CHUNK
|
||||||
(doc_id, topic_id, chunk_content, chunk_embedding, chunk_index, token_count, created_at)
|
(doc_id, topic_id, chunk_content, chunk_embedding, chunk_index, token_count,
|
||||||
VALUES (?, ?, ?, ?::vector, ?, ?, ?)
|
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,
|
jdbcTemplate.update(sql,
|
||||||
docInfo.getDocId(),
|
docInfo.getDocId(),
|
||||||
topicInfo.getTopicId(),
|
topicInfo.getTopicId(),
|
||||||
@@ -141,10 +183,27 @@ public class DocumentIndexingService {
|
|||||||
embedding,
|
embedding,
|
||||||
chunk.getIndex(),
|
chunk.getIndex(),
|
||||||
chunk.getTokenCount(),
|
chunk.getTokenCount(),
|
||||||
|
summary,
|
||||||
|
keywords,
|
||||||
|
questions,
|
||||||
|
"text",
|
||||||
LocalDateTime.now()
|
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) {
|
public List<Double> createEmbedding(String text) {
|
||||||
|
log.info("[임베딩] 사용 모델: {}", embeddingModel); // 모델 확인 로그
|
||||||
|
|
||||||
EmbeddingRequest request = EmbeddingRequest.builder()
|
EmbeddingRequest request = EmbeddingRequest.builder()
|
||||||
.model(embeddingModel)
|
.model(embeddingModel)
|
||||||
.input(Collections.singletonList(text))
|
.input(Collections.singletonList(text))
|
||||||
@@ -33,7 +35,10 @@ public class EmbeddingService {
|
|||||||
|
|
||||||
EmbeddingResult result = openAiService.createEmbeddings(request);
|
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:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: ragone
|
name: ragone
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
active: local
|
active: local # 활성 프로파일 (local, dev, prod)
|
||||||
|
|
||||||
|
# 데이터베이스 연결 설정 (PostgreSQL + pgvector)
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://172.25.0.79:5432/turbosoft_rag_db
|
url: jdbc:postgresql://172.25.0.79:5432/turbosoft_rag_db
|
||||||
username: turbosoft
|
username: turbosoft
|
||||||
password: xjqhthvmxm123
|
password: xjqhthvmxm123
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
|
|
||||||
|
# JPA/Hibernate 설정
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: validate
|
ddl-auto: validate # 스키마 검증만 (create, update, validate, none)
|
||||||
show-sql: true
|
show-sql: true # SQL 로그 출력
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
format_sql: true # SQL 포맷팅
|
||||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|
||||||
|
# 파일 업로드 제한
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 50MB
|
max-file-size: 50MB # 단일 파일 최대 크기
|
||||||
max-request-size: 50MB
|
max-request-size: 50MB # 요청 전체 최대 크기
|
||||||
|
|
||||||
# OpenAI 설정
|
# -----------------------------------------------------
|
||||||
|
# OpenAI API 설정
|
||||||
|
# -----------------------------------------------------
|
||||||
openai:
|
openai:
|
||||||
api-key: ${OPENAI_API_KEY:your-api-key-here}
|
api-key: ${OPENAI_API_KEY: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}
|
||||||
model:
|
model:
|
||||||
embedding: text-embedding-3-small
|
embedding: text-embedding-3-small # 임베딩 모델 (텍스트 → 벡터 변환)
|
||||||
chat: gpt-4o-mini
|
chat: gpt-4o # 채팅 모델 - 일단 gpt-4o로 테스트
|
||||||
|
|
||||||
# RAG 설정
|
# -----------------------------------------------------
|
||||||
|
# RAG (Retrieval-Augmented Generation) 설정
|
||||||
|
# -----------------------------------------------------
|
||||||
rag:
|
rag:
|
||||||
|
# 문서 청킹 설정
|
||||||
chunk:
|
chunk:
|
||||||
size: 1000
|
size: 2000 # 청크 크기 (토큰 수) - 클수록 문맥 많이 포함
|
||||||
overlap: 100
|
overlap: 100 # 청크 간 오버랩 - 문맥 끊김 방지
|
||||||
|
|
||||||
|
# 벡터 검색 설정
|
||||||
retrieval:
|
retrieval:
|
||||||
top-k: 10
|
top-k: 10 # 검색할 최대 청크 개수
|
||||||
similarity-threshold: 0.3 # 더 낮춰서 검색 범위 확대
|
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:
|
file:
|
||||||
upload-dir: ./uploads
|
upload-dir: ./uploads # 업로드 파일 저장 디렉토리
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 로깅 설정
|
||||||
|
# -----------------------------------------------------
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
kr.co.ragone: DEBUG
|
kr.co.ragone: DEBUG # 애플리케이션 로그 레벨
|
||||||
org.hibernate.SQL: DEBUG
|
org.hibernate.SQL: DEBUG # SQL 쿼리 로그
|
||||||
|
# org.hibernate.type.descriptor.sql: TRACE # SQL 파라미터 값 (필요시 활성화)
|
||||||
|
|||||||
Reference in New Issue
Block a user