diff --git a/build.gradle b/build.gradle index 941e596..6886bb9 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,10 @@ dependencyManagement { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'org.springframework.ai:spring-ai-advisors-vector-store' + + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/data/eval_result.json b/data/eval_result.json new file mode 100644 index 0000000..e4a7a5b --- /dev/null +++ b/data/eval_result.json @@ -0,0 +1,22 @@ +{ + "total": 150, + "correct": 112, + "incorrect": 38, + "error": 0, + "accuracy": 0.7466666666666667, + "tier_results": { + "easy": { + "correct": 23, + "total": 30 + }, + "medium": { + "correct": 69, + "total": 94 + }, + "hard": { + "correct": 20, + "total": 26 + } + }, + "elapsed_seconds": 556.8951289653778 +} diff --git a/data/evaluate.py b/data/evaluate.py index ed941cd..e8da7c9 100644 --- a/data/evaluate.py +++ b/data/evaluate.py @@ -80,9 +80,9 @@ def judge_answer(question: str, expected: str, actual: str) -> dict: 실제 답변 (챗봇): {actual} 실제 답변이 기대 답변과 사실적으로 일치하는지 평가하세요. -- 표현이 달라도 핵심 사실이 같으면 정답입니다 -- 핵심 사실이 빠져있거나 틀렸으면 오답입니다 -- 부분적으로만 맞으면 오답으로 처리하세요 +- 질문에 필요한 핵심 사실을 정확히 답했다면, 기대 답변보다 짧거나 부가 정보가 없어도 정답입니다. +- 핵심 사실이 빠졌거나 틀렸거나, 기대 답변과 충돌하는 정보가 있으면 오답입니다. +- 문장 일치가 아니라 질문에 대한 충분성을 기준으로 판단하세요. JSON으로만 응답하세요: {{"score": 1, "reason": "..."}} (정답) @@ -138,6 +138,7 @@ def main(): for i, q in enumerate(questions): qid = q.get("id", f"Q{i+1}") question_ko = q["question_ko"] + question_en = q["question_en"] expected = q["expected_answer"] tier = q.get("tier", "unknown") @@ -147,6 +148,7 @@ def main(): # 서버에 질문 response = ask_server(question_ko) +# response = ask_server(question_en) if response is None: results["error"] += 1 if args.verbose: diff --git a/mission/wall-report.md b/mission/wall-report.md index 4994eca..ca7e9ea 100644 --- a/mission/wall-report.md +++ b/mission/wall-report.md @@ -7,36 +7,87 @@ > 구현하면서 잘 안 됐던 것, 예상과 달랐던 것을 적어주세요. -- - +### 토큰 사용량 개선 +- 원인 + - 매 요청마다 입력 토큰이 약 9,600개가 소모되는 상황이였습니다. + - FAQ, Policy, Chatlog 세 개의 데이터를 모두 컨텍스트로 제공함에 따라 토큰 사용량이 늘어나는 것은 당연함을 알게 됐습니다. +- 고민 + - 세 개의 데이터를 모두 컨텍스트로 제공해도, 답변을 하는 과정에서 불필요한 데이터가 분명 있을 것이라 생각했습니다. + - 그렇다면 답변에 필요한 컨텍스트만 제공해야겠다는 생각이 들었고, 관련해서 Spring AI 공식문서를 찾던 중 ETL 파이프라인에 대해서 알게 됐습니다. +- 해결 + - Spring AI에서 제공해주는 DocumentReader/Transformer 구현체를 그대로 사용하지 않고 각 데이터에 맞는 Reader 클래스를 구현했습니다. + - 각 데이터 레이어가 이미 정형화되어 있어 토큰 분할이 불필요하고, DocumentTransformer로 메타데이터를 뽑을 때마다 임베딩 모델을 호출하기 때문에 불필요한 비용이 발생한다고 판단했기 때문입니다. + - 이후, VectorStore에서 사용자 질문과 연관된 내용을 찾아 AI에게 컨텍스트를 제공함으로써 토큰 사용량이 9,600개에서 592개로 줄어들었습니다. +- 학습 + - 임베딩, 코사인 유사도 개념에 대해서 학습했습니다. + - Spring AI의 ETL 파이프라인에 대해서 학습했습니다. + +### 평가 기준 개선 +- 원인 + - `적립 포인트 1점은 얼마인가요?`라는 질문에 `1포인트는 1원입니다.`라는 답을 했음에도 오답처리가 됨을 확인했습니다. + - 핵심 정보는 전달했으나, `1,000 포인트 이상부터 사용 가능`이라는 부가적인 정보를 전달하지 않아 오답처리가 됐습니다. +- 고민 + - 질문에 직접 대응하는 답변만 정확히 했다면 정답이라고 봐야한다고 생각했습니다. + - 개인적으로 AI에게 질문을 던졌을 때, 답변 이외의 부가정보를 너무 많이 전달하여 어디서부터 읽어야할지 혼란을 겪은 경험이 있었기 때문입니다. + - 다만, 기업의 관점에서는 한 번에 많은 정보를 담은 정보를 전달하여 리소스 절감을 하는데 목적이 있을 수 있다고 생각했습니다. +- 해결 + - 결론적으로는 직접 대응하는 답변을 정확히 했다면 정답 처리가 되도록 검증 스크립트를 수정했습니다. + - 사용자 입장에서도 필요한 정보만 전달하는 것이 좋다고 판단했고, 많은 정보를 전달하는 것이 사용자 경험상으로 불편함을 느낄 수 있을 가능성이 높다고 생각했기 때문입니다. + - 정확도가 `81개 -> 113개`로 증가했지만, 평균 응답 시간이 `2.6초 -> 3.3초`로 증가함을 확인했습니다. + - 프롬프트 개선을 통해 정확도가 `113개 -> 108개`로 감소했지만, 평균 응답 시간이 `3.3초 -> 2.7초`로 감소함을 확인했습니다. +- 학습 + - 평가 기준에 대해서 고민하고 개선하는 경험을 할 수 있었습니다. + +### 할루시네이션 개선 +- 원인 + - `VIP 등급 조건`을 물어보는 질문에 대해서 `800만원`이라고 계속 답하여 오답처리가 됨을 확인했습니다. + - 해당 질문의 컨텍스트로 제공되는 데이터를 확인한 결과 Chatlog에서 상담원이 800만원이라 답변한 로그가 존재했고, FAQ에는 80만원이라고 명시되어 있었습니다. +- 고민 + - Chatlog에서 agent_accuracy가 correct 처리가 되어 있어도, 잘못된 데이터가 있을 수 있으니 필터링을 해야하는 상황이였습니다. + - Chatlog의 데이터는 너무 많아 직접 확인해서 라벨링을 수정하는 과정은 리소스가 많이 든다고 생각했습니다. + - 또한, Chatlog를 임베딩할 때 OpenAI API를 호출하여 Chatlog 데이터가 FAQ와 Policy의 내용과 적합한지를 판단하고 적절하다면 임베딩하는 방법을 생각했습니다. + - 애플리케이션이 실행될 때마다 임베딩을 하기 때문에 비용이 발생하기 때문에 적용하기 어렵다고 생각했습니다. +- 해결 + - `FAQ -> Policy -> Chatlog` 순서로 정렬해서 컨텍스트를 정렬해서 제공하면 우선적으로 FAQ를 읽기 때문에 할루시네이션이 해소될 것이라 생각했습니다. + - 여전히 800만원으로 답변을 했습니다. + - Chatlog에 부정확한 데이터가 존재하기 때문에 이를 제외하고 FAQ와 Policy만 제공하면 할루시네이션을 개선할 수 있을 거 같았습니다. + - 80만원으로 답변을 했지만, 정확도가 `113개 -> 88개`로 감소했습니다. + - Chatlog를 포함하되, FAQ와 Policy의 내용 검증을 통해 일치한 경우에만 참고하도록 프롬프트를 수정했습니다. + - 80만원으로 답변을 했으며, 정확도가 `88개 -> 112개`로 증가했습니다. +- 학습 + - 할루시네이션 개선을 위해 다양한 가설을 세우고 이를 검증하는 경험을 했습니다. + - 라벨링의 중요성을 알게 됐습니다. ## 2. 해결하지 못한 것 > 시도했지만 결국 해결 못한 문제가 있다면 적어주세요. -- - +- 평가 기준과 할루시네이션 개선을 통해 프롬프트를 수정했지만, 간혈적으로 개선된 점이 적용되지 않아 오답처리가 되는 질문들이 있습니다. +- 이를 해결하기 위해 각 데이터 레이어에서 가져오는 데이터의 개수와 유사도를 조절하면서 검증을 진행했으나 개선 결과가 미미했습니다. ## 3. 정확도 측정 결과 > 테스트 질문 100개로 측정한 정확도를 기록해주세요. -| 난이도 | 정확도 | 비고 | -|--------|--------|------| -| easy | | | -| medium | | | -| hard | | | +| 난이도 | 정확도 | 비고 | +|--------|-------|-----| +| easy | 23/30 | 77% | +| medium | 20/26 | 77% | +| hard | 69/94 | 73% | ## 4. 왜 그런 결과가 나왔는지 > 정확도가 낮은 난이도의 질문을 몇 개 살펴보고, 왜 틀렸는지 분석해주세요. -- - +- 핵심 사실을 전달했으나, 부가적인 정보를 전달하지 않아 오답처리가 된 질문들이 몇 개 있었습니다. +- 이는 검증 스크립트에서 호출하는 AI에게 제공하는 프롬프트의 문제라 생각됩니다. +- `저번에 물어본 배송 건 어떻게 됐어요?`와 같은 질문에 대해서 고객의 주문 번호를 요청하지 않고, 직접 확인하라는 답변을 하여 오답처리가 된 질문이 있습니다. +- 현재 구현한 챗봇은 과거의 기록 혹은 이전 질문에 대한 컨텍스트가 없기 때문에 답변이 어렵기 때문에, 이런 유형의 질문이 들어오면 과거의 이력을 조회하기 위한 정보를 요청할 수 있도록 프롬프트를 수정해야할 거 같습니다. ## 5. 개선하고 싶은 것 > 시간이 더 있었다면 시도해보고 싶은 개선점을 적어주세요. -- +- 현재는 프롬프트를 통해 Chatlog에서 노이즈를 우회적으로 제거하고 있지만, 프롬프트 이외의 방법으로 노이즈를 제거할 수 있는 방법이 있다면 적용하여 개선하고 싶습니다. +- 세 개의 난이도에 대해서 평균 80%의 정확도로 답변할 수 있도록 개선하고 싶습니다. diff --git a/src/main/java/com/cholog/bootcamp/config/ChatClientConfig.java b/src/main/java/com/cholog/bootcamp/config/ChatClientConfig.java new file mode 100644 index 0000000..694e4d1 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/config/ChatClientConfig.java @@ -0,0 +1,15 @@ +package com.cholog.bootcamp.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ChatClientConfig { + + @Bean + public ChatClient chatClient(ChatModel chatModel) { + return ChatClient.builder(chatModel).build(); + } +} diff --git a/src/main/java/com/cholog/bootcamp/config/VectorStoreConfig.java b/src/main/java/com/cholog/bootcamp/config/VectorStoreConfig.java new file mode 100644 index 0000000..fa9883b --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/config/VectorStoreConfig.java @@ -0,0 +1,49 @@ +package com.cholog.bootcamp.config; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.cholog.bootcamp.reader.ChatLogReader; +import com.cholog.bootcamp.reader.CurrentPolicyReader; +import com.cholog.bootcamp.reader.FaqReader; +import com.cholog.bootcamp.reader.InternalPolicyReader; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class VectorStoreConfig { + + @Bean + public VectorStore vectorStore(EmbeddingModel embeddingModel) { + return SimpleVectorStore.builder(embeddingModel).build(); + } + + @Bean + public ApplicationRunner vectorStoreInitializer( + FaqReader faqReader, + CurrentPolicyReader currentPolicyReader, + InternalPolicyReader internalPolicyReader, + ChatLogReader chatLogReader, + VectorStore vectorStore + ) { + return args -> { + List documents = new ArrayList<>(); + documents.addAll(faqReader.read()); + documents.addAll(currentPolicyReader.read()); + documents.addAll(internalPolicyReader.read()); + documents.addAll(chatLogReader.read()); + + vectorStore.add(documents); + log.info("Loaded {} documents into VectorStore", documents.size()); + }; + } +} diff --git a/src/main/java/com/cholog/bootcamp/controller/ChatController.java b/src/main/java/com/cholog/bootcamp/controller/ChatController.java new file mode 100644 index 0000000..bbebf66 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/controller/ChatController.java @@ -0,0 +1,27 @@ +package com.cholog.bootcamp.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.cholog.bootcamp.dto.QuestionAskRequest; +import com.cholog.bootcamp.dto.QuestionAskResponse; +import com.cholog.bootcamp.service.ChatService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/chat") +public class ChatController { + + private final ChatService chatService; + + @PostMapping + public ResponseEntity askQuestion(@RequestBody QuestionAskRequest request) { + QuestionAskResponse response = chatService.askQuestion(request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/cholog/bootcamp/dto/QuestionAskRequest.java b/src/main/java/com/cholog/bootcamp/dto/QuestionAskRequest.java new file mode 100644 index 0000000..e97bdc3 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/dto/QuestionAskRequest.java @@ -0,0 +1,7 @@ +package com.cholog.bootcamp.dto; + +public record QuestionAskRequest( + String question +) { + +} diff --git a/src/main/java/com/cholog/bootcamp/dto/QuestionAskResponse.java b/src/main/java/com/cholog/bootcamp/dto/QuestionAskResponse.java new file mode 100644 index 0000000..0525536 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/dto/QuestionAskResponse.java @@ -0,0 +1,27 @@ +package com.cholog.bootcamp.dto; + +public record QuestionAskResponse( + String answer, + InnerTokenUsageResponse tokenUsage +) { + public record InnerTokenUsageResponse( + Integer promptTokens, + Integer completionTokens, + Integer totalTokens + ) { + public static InnerTokenUsageResponse from( + Integer promptTokens, Integer completionTokens, Integer totalTokens + ) { + return new InnerTokenUsageResponse(promptTokens, completionTokens, totalTokens); + } + } + + public static QuestionAskResponse from( + String answer, Integer promptTokens, Integer completionTokens, Integer totalTokens + ) { + return new QuestionAskResponse( + answer, + InnerTokenUsageResponse.from(promptTokens, completionTokens, totalTokens) + ); + } +} diff --git a/src/main/java/com/cholog/bootcamp/reader/ChatLogReader.java b/src/main/java/com/cholog/bootcamp/reader/ChatLogReader.java new file mode 100644 index 0000000..8d85033 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/reader/ChatLogReader.java @@ -0,0 +1,87 @@ +package com.cholog.bootcamp.reader; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.ai.document.Document; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class ChatLogReader { + + private static final Path DIRECTORY = Path.of("data/layer3_chatlogs"); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public List read() { + try (Stream paths = Files.list(DIRECTORY)) { + List documents = new ArrayList<>(); + paths.forEach(path -> documents.addAll(readFile(path))); + return documents; + } catch (IOException e) { + throw new RuntimeException("채팅 로그 디렉토리를 읽는 중 오류가 발생했습니다.", e); + } + } + + private List readFile(Path path) { + try (Stream lines = Files.lines(path, StandardCharsets.UTF_8)) { + List documents = new ArrayList<>(); + lines.forEach(line -> { + Document document = parse(path, line); + if (document != null) { + documents.add(document); + } + }); + return documents; + } catch (IOException e) { + throw new IllegalArgumentException(""); + } + } + + private Document parse(Path path, String line) { + try { + JsonNode root = objectMapper.readTree(line); + if (!"correct".equals(root.path("agent_accuracy").asText())) { + return null; + } + + return toDocument(path, root); + } catch (IOException e) { + throw new IllegalArgumentException(""); + } + } + + private Document toDocument(Path path, JsonNode root) { + Map metadata = new HashMap<>(); + metadata.put("layer", "layer3_chatlogs"); + metadata.put("filepath", path.toString()); + metadata.put("conversation_id", root.path("conversation_id").asText()); + metadata.put("primary_intent", root.path("primary_intent").asText()); + metadata.put("agent_accuracy", root.path("agent_accuracy").asText()); + + return new Document(turns(root.path("turns")), metadata); + } + + private String turns(JsonNode turns) { + StringBuilder sb = new StringBuilder(); + + for (JsonNode turn : turns) { + sb.append(turn.path("role").asText()) + .append(": ") + .append(turn.path("text").asText()) + .append('\n'); + } + + return sb.toString().trim(); + } +} diff --git a/src/main/java/com/cholog/bootcamp/reader/CurrentPolicyReader.java b/src/main/java/com/cholog/bootcamp/reader/CurrentPolicyReader.java new file mode 100644 index 0000000..c363692 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/reader/CurrentPolicyReader.java @@ -0,0 +1,98 @@ +package com.cholog.bootcamp.reader; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.ai.document.Document; +import org.springframework.stereotype.Component; + +@Component +public class CurrentPolicyReader { + + private static final Path DIRECTORY = Path.of("data/layer2_policies/current"); + + public List read() { + try (Stream paths = Files.list(DIRECTORY)) { + List documents = new ArrayList<>(); + paths.sorted() + .forEach(path -> documents.addAll(readFile(path))); + return documents; + } catch (IOException e) { + throw new RuntimeException("정책 디렉토리를 읽는 중 오류가 발생했습니다.", e); + } + } + + private List readFile(Path path) { + try { + return parse(path, Files.readAllLines(path, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalArgumentException(""); + } + } + + private List parse(Path path, List lines) { + List documents = new ArrayList<>(); + String category = null; + String section = null; + StringBuilder sectionBody = new StringBuilder(); + + for (String line : lines) { + if (line.startsWith("# ")) { + category = line.substring(2).trim(); + continue; + } + + if (category == null) { + continue; + } + + if (line.startsWith("## ")) { + addDocument(documents, path, category, section, sectionBody); + section = line.substring(3).trim(); + sectionBody.setLength(0); + continue; + } + + if (section != null) { + sectionBody.append(line).append('\n'); + } + } + + addDocument(documents, path, category, section, sectionBody); + + return documents; + } + + private void addDocument( + List documents, + Path path, + String category, + String section, + StringBuilder sectionBody + ) { + if (section == null) { + return; + } + + String body = sectionBody.toString().trim(); + if (body.isBlank()) { + return; + } + + Map metadata = new HashMap<>(); + metadata.put("layer", "layer2_policies"); + metadata.put("policy_scope", "current"); + metadata.put("filepath", path.toString()); + metadata.put("category", category); + metadata.put("section", section); + + documents.add(new Document(body, metadata)); + } +} diff --git a/src/main/java/com/cholog/bootcamp/reader/FaqReader.java b/src/main/java/com/cholog/bootcamp/reader/FaqReader.java new file mode 100644 index 0000000..0e0b2c0 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/reader/FaqReader.java @@ -0,0 +1,95 @@ +package com.cholog.bootcamp.reader; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.ai.document.Document; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class FaqReader { + + private static final Path FAQ_DIRECTORY = Path.of("data/layer1_faq"); + + public List read() { + try (Stream paths = Files.list(FAQ_DIRECTORY)) { + List documents = new ArrayList<>(); + paths.forEach(path -> documents.addAll(readFile(path))); + return documents; + } catch (IOException e) { + throw new RuntimeException("FAQ 디렉토리를 읽는 중 오류가 발생했습니다.", e); + } + } + + private List readFile(Path path) { + try { + return parse(path, Files.readAllLines(path, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalArgumentException(""); + } + } + + private List parse(Path path, List lines) { + List documents = new ArrayList<>(); + String category = null; + String question = null; + StringBuilder body = new StringBuilder(); + + for (String line : lines) { + if (line.startsWith("# ")) { + category = line.substring(2).trim(); + continue; + } + + if (line.startsWith("## ")) { + continue; + } + + if (line.startsWith("### ")) { + addDocument(documents, path, category, question, body); + question = line.substring(4).trim(); + body.setLength(0); + continue; + } + + if (question != null) { + body.append(line).append('\n'); + } + } + + addDocument(documents, path, category, question, body); + + return documents; + } + + private void addDocument( + List documents, Path path, String category, String question, StringBuilder body + ) { + if (question == null) { + return; + } + + String text = body.toString().trim(); + if (text.isBlank()) { + return; + } + + Map metadata = new HashMap<>(); + metadata.put("question", question); + metadata.put("layer", "layer1_faq"); + metadata.put("filepath", path.toString()); + metadata.put("category", category); + + documents.add(new Document(text, metadata)); + } +} diff --git a/src/main/java/com/cholog/bootcamp/reader/InternalPolicyReader.java b/src/main/java/com/cholog/bootcamp/reader/InternalPolicyReader.java new file mode 100644 index 0000000..7bf263d --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/reader/InternalPolicyReader.java @@ -0,0 +1,94 @@ +package com.cholog.bootcamp.reader; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.ai.document.Document; +import org.springframework.stereotype.Component; + +@Component +public class InternalPolicyReader { + + private static final Path DIRECTORY = Path.of("data/layer2_policies/internal"); + + public List read() { + try (Stream paths = Files.list(DIRECTORY)) { + List documents = new ArrayList<>(); + paths.sorted() + .forEach(path -> documents.addAll(readFile(path))); + return documents; + } catch (IOException e) { + throw new RuntimeException("내부 정책 디렉토리를 읽는 중 오류가 발생했습니다.", e); + } + } + + private List readFile(Path path) { + try { + return parse(path, Files.readAllLines(path, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalArgumentException(""); + } + } + + private List parse(Path path, List lines) { + List documents = new ArrayList<>(); + String category = null; + String section = null; + StringBuilder sectionBody = new StringBuilder(); + + for (String line : lines) { + if (line.startsWith("# ")) { + category = line.substring(2).trim(); + continue; + } + + if (category == null) { + continue; + } + + if (line.startsWith("## ")) { + addDocument(documents, path, category, section, sectionBody); + section = line.substring(3).trim(); + sectionBody.setLength(0); + continue; + } + + if (section != null) { + sectionBody.append(line).append('\n'); + } + } + + addDocument(documents, path, category, section, sectionBody); + + return documents; + } + + private void addDocument( + List documents, Path path, String category, String section, StringBuilder sectionBody + ) { + if (section == null) { + return; + } + + String body = sectionBody.toString().trim(); + if (body.isBlank()) { + return; + } + + Map metadata = new HashMap<>(); + metadata.put("layer", "layer2_internal"); + metadata.put("policy_scope", "internal"); + metadata.put("filepath", path.toString()); + metadata.put("category", category); + metadata.put("section", section); + + documents.add(new Document(body, metadata)); + } +} diff --git a/src/main/java/com/cholog/bootcamp/service/ChatService.java b/src/main/java/com/cholog/bootcamp/service/ChatService.java new file mode 100644 index 0000000..25a5c84 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/service/ChatService.java @@ -0,0 +1,103 @@ +package com.cholog.bootcamp.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.stereotype.Service; + +import com.cholog.bootcamp.dto.QuestionAskRequest; +import com.cholog.bootcamp.dto.QuestionAskResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatService { + + private static final int FAQ_TOP_K = 4; + private static final int CURRENT_POLICY_TOP_K = 3; + private static final int INTERNAL_POLICY_TOP_K = 2; + private static final int CHAT_LOG_TOP_K = 2; + + private final ChatClient chatClient; + private final VectorStore vectorStore; + + public QuestionAskResponse askQuestion(QuestionAskRequest request) { + String context = getContext(request.question()); + + ChatResponse chatResponse = chatClient.prompt() + .system(""" + 당신은 초록 코퍼레이션에서 고객지원을 담당하고 있습니다. + 제공된 참고자료를 바탕으로 질문에 대한 답변을 진행해주세요. + + 답변 간 유의 사항은 다음과 같습니다. + - 초록 코퍼레이션과 무관한 내용은 답변하지 마세요. + - 참고자료에 없는 내용을 추측해서 답변하지 마세요. + - Chatlog의 경우 FAQ, Current Policies, Internal Policies의 내용들과 비교했을 때 불일치할 경우 답변간 근거로 사용하지 마세요. + """) + .user(""" + 참고 자료 + %s + + 질문 + %s + """.formatted(context, request.question())) + .call() + .chatResponse(); + Usage usage = chatResponse.getMetadata().getUsage(); + + return QuestionAskResponse.from( + chatResponse.getResult().getOutput().getText(), + usage.getPromptTokens(), + usage.getCompletionTokens(), + usage.getTotalTokens() + ); + } + + private String getContext(String question) { + return """ + FAQ + %s + + Current Policies + %s + + Internal Policies + %s + + Chat Logs + %s + """.formatted( + toContext(searchDocuments(question, FAQ_TOP_K, "'layer1_faq'")), + toContext(searchDocuments(question, CURRENT_POLICY_TOP_K, "'layer2_policies'")), + toContext(searchDocuments(question, INTERNAL_POLICY_TOP_K, "'layer2_internal'")), + toContext(searchDocuments(question, CHAT_LOG_TOP_K, "'layer3_chatlogs'")) + ); + } + + private List searchDocuments(String question, int topK, String layer) { + return search(question, topK, "layer == " + layer); + } + + private List search(String question, int topK, String filterExpression) { + SearchRequest searchRequest = SearchRequest.builder() + .query(question) + .topK(topK) + .filterExpression(filterExpression) + .build(); + + return vectorStore.similaritySearch(searchRequest); + } + + private String toContext(List documents) { + return documents.stream() + .map(Document::getText) + .collect(Collectors.joining("\n\n")); + } +}