diff --git a/build.gradle b/build.gradle index 941e596..e906914 100644 --- a/build.gradle +++ b/build.gradle @@ -26,8 +26,12 @@ 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-vector-store' testImplementation 'org.springframework.boot:spring-boot-starter-test' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/data/eval_result.json b/data/eval_result.json new file mode 100644 index 0000000..b61a9d4 --- /dev/null +++ b/data/eval_result.json @@ -0,0 +1,68 @@ +{ + "total": 150, + "correct": 140, + "incorrect": 10, + "error": 0, + "accuracy": 0.9333, + "kpi": { + "refusal_quality": { + "score_0": 4, + "score_1": 0, + "score_2": 146, + "total": 150 + }, + "core_response": { + "score_0": 6, + "score_1": 2, + "score_2": 142, + "total": 150 + }, + "factuality": { + "score_0": 2, + "score_1": 5, + "score_2": 143, + "total": 150 + }, + "front_loaded": { + "score_0": 7, + "score_1": 143, + "total": 150 + }, + "restraint": { + "score_0": 4, + "score_1": 146, + "total": 150 + }, + "conciseness": { + "score_0": 1, + "score_1": 149, + "total": 150 + }, + "final_pass": { + "score_0": 10, + "score_1": 140, + "total": 150 + } + }, + "tier_results": { + "easy": { + "correct": 29, + "total": 30 + }, + "medium": { + "correct": 87, + "total": 94 + }, + "hard": { + "correct": 24, + "total": 26 + } + }, + "elapsed_seconds": 70.1703679561615, + "avg_response_seconds": 4.527638580004374, + "chatbot_token_usage": { + "prompt_tokens": 278086, + "completion_tokens": 7064, + "total_tokens": 285150 + } +} \ No newline at end of file diff --git a/data/evaluate.py b/data/evaluate.py index ed941cd..408c614 100644 --- a/data/evaluate.py +++ b/data/evaluate.py @@ -2,7 +2,18 @@ 챗봇 품질 평가 스크립트 실행 중인 서버(localhost:8080)에 테스트 질문을 보내고, -LLM 판정으로 정확도를 측정합니다. +LLM 판정으로 KPI 지표를 측정합니다. + +KPI 지표: + refusal_quality 거절 품질 — 거절이 필요한 상황에서 적절히 거절했는가 (0/1/2) + core_response 핵심 응답 성공 — 질문이 요구하는 정보에 직접 답변했는가 (0/1/2) + factuality 사실성 — 기대 답변과 모순되는 내용이 없는가 (0/1/2) + front_loaded 두괄식 응답 — 첫 문장에 직접 답변이 있는가 (0/1) + restraint 정보 절제력 — 부가 정보(논리 단위)가 1개 이하인가 (0/1) + conciseness 간결성 — 응답이 200자 이하인가 (Python 계산, 0/1) + final_pass 최종 통과 — refusal_quality=1 → 통과 + refusal_quality=0 → 탈락 + refusal_quality=2 → core_response + factuality == 4 사전 준비: python -m venv .venv @@ -11,8 +22,9 @@ 실행: # 서버가 localhost:8080에서 실행 중이어야 합니다 .venv/bin/python evaluate.py - .venv/bin/python evaluate.py --verbose # 질문별 상세 출력 - .venv/bin/python evaluate.py --limit 10 # 처음 10개만 평가 + .venv/bin/python evaluate.py --verbose # 질문별 상세 출력 + .venv/bin/python evaluate.py --limit 10 # 처음 10개만 평가 + .venv/bin/python evaluate.py --parallel 10 # 병렬 워커 10개로 가속 비용: judge 모델(gpt-4o-mini) 사용, 100문항 기준 약 $0.3~0.5 @@ -22,6 +34,7 @@ import os import argparse import time +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import requests @@ -35,8 +48,8 @@ SERVER_URL = "http://localhost:8080/api/chat" JUDGE_MODEL = "gpt-4o-mini" +CONCISENESS_MAX_CHARS = 200 -# .env에서 API 키 로드 env_path = ROOT_DIR / ".env" env_vars = dotenv_values(env_path) OPENAI_API_KEY = env_vars.get("OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY") @@ -70,24 +83,84 @@ def ask_server(question: str) -> dict | None: # ─── LLM 판정 ───────────────────────────────────────────────────────────────── def judge_answer(question: str, expected: str, actual: str) -> dict: - """LLM으로 답변의 사실적 일치도를 판정합니다.""" - prompt = f"""당신은 FAQ 챗봇 답변의 품질을 평가하는 판정자입니다. - -질문: {question} - -기대 답변 (정답): {expected} - -실제 답변 (챗봇): {actual} - -실제 답변이 기대 답변과 사실적으로 일치하는지 평가하세요. -- 표현이 달라도 핵심 사실이 같으면 정답입니다 -- 핵심 사실이 빠져있거나 틀렸으면 오답입니다 -- 부분적으로만 맞으면 오답으로 처리하세요 - -JSON으로만 응답하세요: -{{"score": 1, "reason": "..."}} (정답) -{{"score": 0, "reason": "..."}} (오답) -""" + """LLM으로 KPI 지표를 단일 호출로 판정합니다.""" + prompt = f"""당신은 챗봇 답변 품질을 평가하는 판정자입니다. + +[질문]: {question} +[기대 답변]: {expected} +[실제 답변]: {actual} + +아래 5개 지표를 각각 독립적으로 판정하세요. + +1. refusal_quality — 거절 여부와 적절성 + 2: 거절 없음 (정상 답변) + 1: [기대 답변]이 비어 있거나 내용이 정보 없음 이고 [실제 답변]도 적절히 거절 + 0: [기대 답변]에 답변 내용이 있는데 [실제 답변]이 거절 + +2. core_response — 질문이 요구하는 정보에 직접 답했는가? + 2: 질문의 핵심 정보에 완전히 답변하며 [기대 답변] 사실과 일치 + - 질문이 수치를 묻는 경우 정확한 수치를 답변 + (예: "반품 기간이 며칠이에요?" → "14일입니다"라고 답변) + - 질문이 조건을 묻는 경우 조건과 적용 대상을 정확히 답변 + (예: "VIP도 배송비 내야 해요?" → "VIP는 무료입니다"라고 답변) + - 질문이 방법을 묻는 경우 구체적인 절차를 답변 + (예: "반품 어떻게 해요?" → 반품 신청 경로와 절차를 답변) + 1: 질문에 답변했으나 핵심 정보 일부 누락 + - 수치는 맞으나 적용 조건을 빠뜨림 + (예: "언제 환불돼요?" → "3~5일 걸립니다"라고만 답변, 결제수단별 차이 미언급) + - 방법은 맞으나 핵심 단계 일부 누락 + (예: 반품 절차 안내 시 사진 첨부 단계 누락) + 0: 아래 중 하나에 해당 + - 질문이 요구하는 정보를 전혀 제공하지 않음 + - 질문과 무관한 내용만 답변 + - "고객센터에 문의하세요"처럼 답변을 회피 + + +3. factuality — 실제 답변이 [기대 답변]과 모순되는 내용이 없는가? + 수치뿐 아니라 조건·논리·인과관계도 포함하여 검토하세요. + + 2: 답변 내 모든 내용이 기대 답변과 일치 + - 수치·기간이 정확히 같음 + (예: 반품 기간 14일 → "14일"이라고 답변) + - 조건과 적용 대상이 정확히 같음 + (예: "VIP만 무료" → "VIP만 무료"라고 답변) + - 인과관계가 정확히 같음 — 원인과 결과를 모두 포함 + (예: "반품하면 포인트 차감" → "반품 시 포인트가 차감됩니다"라고 답변) + ※ 결과만 언급하고 원인 조건을 빠뜨리면 1점 + 1: 질문에 대한 직접적인 핵심 답변은 맞으나 부가 설명이 기대 답변과 다른 내용 포함 + - Q: "반품 기간이 며칠이에요?" + 핵심 답변(반품 기간): "14일입니다" → 맞음 + 부가 설명(틀린 정보): "단, 냉장 상품은 7일입니다" → 기대 답변에 없는 틀린 정보 + + - Q: "VIP는 배송비 무료인가요?" + 핵심 답변(VIP 배송비): "네, 무료입니다" → 맞음 + 부가 설명(틀린 정보): "단, 구독 중인 경우에만 적용됩니다" → 기대 답변과 다른 조건 추가 + + - Q: "포인트 적립률이 몇 퍼센트예요?" + 핵심 답변(적립률): "3%입니다" → 맞음 + 부가 설명(틀린 정보): "VIP 회원은 10% 적립됩니다" → 기대 답변의 수치와 다름 + 0: 아래 중 하나에 해당 + - 수치·기간이 기대 답변과 다름 + (예: 반품 기간 14일 → "7일"이라고 답변) + - 조건·적용 대상이 반전됨 + (예: "VIP만 무료" → "모든 회원 무료"라고 답변) + - 인과관계가 반전됨 + (예: "반품하면 등급 하락" → "등급에 영향 없다"고 답변) + - 기대 답변에 없는 구체적 수치·정책을 만들어서 답변 + + +4. front_loaded — 첫 문장에 [질문]에 대한 직접 답변이 있는가? + 1: 첫 문장에 질문이 요구하는 정보를 직접 전달 + 1: 거절 응답 (거절 의사가 첫 문장에 명확히 표현) + 0: 서론·공감·확인 문구("안녕하세요", "좋은 질문이에요" 등)로 시작 + +5. restraint — [질문]에 대한 직접 답변 외 부가 정보가 1개 이하인가? + [질문]이 여러 항목을 묻는 경우, 각 항목의 답변은 직접 답변으로 간주 (부가 집계 제외) + 1: 부가 정보 1개 이하 + 0: 부가 정보 2개 이상 + +JSON으로만 응답: +{{"refusal_quality":2,"core_response":2,"factuality":2,"front_loaded":1,"restraint":1,"reasons":{{"refusal_quality":"...","core_response":"...","factuality":"...","front_loaded":"...","restraint":"..."}}}}""" resp = openai_client.chat.completions.create( model=JUDGE_MODEL, @@ -96,10 +169,73 @@ def judge_answer(question: str, expected: str, actual: str) -> dict: response_format={"type": "json_object"}, ) + usage = resp.usage try: - return json.loads(resp.choices[0].message.content) + result = json.loads(resp.choices[0].message.content) except json.JSONDecodeError: - return {"score": 0, "reason": "판정 파싱 실패"} + result = { + "refusal_quality": 0, "core_response": 0, "factuality": 0, + "front_loaded": 0, "restraint": 0, + "reasons": {k: "판정 파싱 실패" for k in + ("refusal_quality", "core_response", "factuality", "front_loaded", "restraint")}, + } + + result["judge_usage"] = { + "prompt_tokens": usage.prompt_tokens, + "completion_tokens": usage.completion_tokens, + "total_tokens": usage.total_tokens, + } + return result + + +# ─── 워커 ───────────────────────────────────────────────────────────────────── + +def process_question(q: dict, idx: int) -> dict: + """질문 1건을 처리해 결과 dict를 반환합니다. (스레드 안전)""" + start = time.time() + qid = q.get("id", f"Q{idx+1}") + question_ko = q["question_ko"] + expected = q["expected_answer"] + tier = q.get("tier", "unknown") + + response = ask_server(question_ko) + if response is None: + return {"qid": qid, "tier": tier, "status": "error", "question": question_ko, + "token_usage": {}, "duration": time.time() - start} + + actual_answer = response.get("answer", "") + token_usage = response.get("tokenUsage", {}) + judgment = judge_answer(question_ko, expected, actual_answer) + + refusal_quality = judgment.get("refusal_quality", 0) + core_response = judgment.get("core_response", 0) + factuality = judgment.get("factuality", 0) + + if refusal_quality == 1: + final_pass = 1 + elif refusal_quality == 0: + final_pass = 0 + else: # refusal_quality == 2 (정상 답변) + final_pass = int(core_response + factuality == 4) + + return { + "qid": qid, + "tier": tier, + "status": "ok", + "question": question_ko, + "token_usage": token_usage, + "duration": time.time() - start, + "kpi": { + "refusal_quality": refusal_quality, + "core_response": core_response, + "factuality": factuality, + "front_loaded": judgment.get("front_loaded", 0), + "restraint": judgment.get("restraint", 0), + "conciseness": int(len(actual_answer) <= CONCISENESS_MAX_CHARS), + "final_pass": final_pass, + }, + "reasons": judgment.get("reasons", {}), + } # ─── 메인 ───────────────────────────────────────────────────────────────────── @@ -108,9 +244,9 @@ def main(): parser = argparse.ArgumentParser(description="챗봇 품질 평가") parser.add_argument("--verbose", action="store_true", help="질문별 상세 출력") parser.add_argument("--limit", type=int, default=0, help="평가할 질문 수 제한 (0=전체)") + parser.add_argument("--parallel", type=int, default=1, help="병렬 워커 수 (default: 1, 순차 실행)") args = parser.parse_args() - # 테스트 질문 로드 questions_path = DATA_DIR / "test_questions.json" with open(questions_path) as f: questions = json.load(f) @@ -122,95 +258,158 @@ def main(): print(f"서버: {SERVER_URL}") print(f"질문 수: {len(questions)}") print(f"판정 모델: {JUDGE_MODEL}") + if args.parallel > 1: + print(f"병렬 워커: {args.parallel}") print() - # 서버 연결 확인 test_resp = ask_server("test") if test_resp is None: print("서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하세요:") print(f" ./gradlew bootRun") return - results = {"correct": 0, "incorrect": 0, "error": 0} + error_count = 0 + kpi_totals = { + "refusal_quality": {"score_0": 0, "score_1": 0, "score_2": 0}, + "core_response": {"score_0": 0, "score_1": 0, "score_2": 0}, + "factuality": {"score_0": 0, "score_1": 0, "score_2": 0}, + "front_loaded": {"score_0": 0, "score_1": 0}, + "restraint": {"score_0": 0, "score_1": 0}, + "conciseness": {"score_0": 0, "score_1": 0}, + "final_pass": {"score_0": 0, "score_1": 0}, + } tier_results = {} + chatbot_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + durations = [] start_time = time.time() - for i, q in enumerate(questions): - qid = q.get("id", f"Q{i+1}") - question_ko = q["question_ko"] - expected = q["expected_answer"] - tier = q.get("tier", "unknown") - - if tier not in tier_results: - tier_results[tier] = {"correct": 0, "total": 0} - tier_results[tier]["total"] += 1 - - # 서버에 질문 - response = ask_server(question_ko) - if response is None: - results["error"] += 1 - if args.verbose: - print(f"[{qid}] ERROR — 서버 응답 없음") - continue - - actual_answer = response.get("answer", "") - - # LLM 판정 - judgment = judge_answer(question_ko, expected, actual_answer) - score = judgment.get("score", 0) - - if score == 1: - results["correct"] += 1 - tier_results[tier]["correct"] += 1 - marker = "✓" - else: - results["incorrect"] += 1 - marker = "✗" - - if args.verbose: - print(f"[{qid}] {marker} ({tier}) {question_ko[:40]}...") - if score == 0: - print(f" 이유: {judgment.get('reason', '')[:80]}") - - # 진행률 (10개마다) - if not args.verbose and (i + 1) % 10 == 0: - print(f" 진행: {i+1}/{len(questions)}") - - # 결과 출력 + def handle_result(r): + nonlocal error_count + _aggregate(r, kpi_totals, tier_results, chatbot_usage, durations, args.verbose) + if r["status"] == "error": + error_count += 1 + + # ─── 실행 (순차 / 병렬 공통 집계) ──────────────────────────────────────── + if args.parallel > 1: + with ThreadPoolExecutor(max_workers=args.parallel) as executor: + futures = [executor.submit(process_question, q, i) for i, q in enumerate(questions)] + for completed, fut in enumerate(as_completed(futures), 1): + handle_result(fut.result()) + if not args.verbose and completed % 10 == 0: + print(f" 진행: {completed}/{len(questions)}") + else: + for i, q in enumerate(questions): + handle_result(process_question(q, i)) + if not args.verbose and (i + 1) % 10 == 0: + print(f" 진행: {i+1}/{len(questions)}") + + # ─── 결과 출력 ──────────────────────────────────────────────────────────── elapsed = time.time() - start_time - total = results["correct"] + results["incorrect"] + results["error"] + total = len(questions) + evaluated = total - error_count + + def pct(n): return n / max(evaluated, 1) * 100 print() - print(f"=== 평가 결과 ===") - print(f"전체: {results['correct']}/{total} ({results['correct']/max(total,1)*100:.1f}%)") - print() + print(f"=== KPI 결과 ({total}문항) ===") + + # 0/1/2 지표: 분포 출력 + for key, label in [ + ("refusal_quality", "거절 품질 "), + ("core_response", "핵심 응답 성공 "), + ("factuality", "사실성 "), + ]: + t = kpi_totals[key] + print(f" {label}: 완전 {t['score_2']}({pct(t['score_2']):.0f}%) | 부분 {t['score_1']}({pct(t['score_1']):.0f}%) | 실패 {t['score_0']}({pct(t['score_0']):.0f}%)") + + # 0/1 지표: 통과/실패 출력 + for key, label in [ + ("front_loaded", "두괄식 응답 "), + ("restraint", "정보 절제력 "), + ("conciseness", f"간결성 ≤{CONCISENESS_MAX_CHARS}자 "), + ]: + t = kpi_totals[key] + print(f" {label}: 통과 {t['score_1']}({pct(t['score_1']):.1f}%) | 실패 {t['score_0']}({pct(t['score_0']):.1f}%)") + + print(f" {'─' * 36}") + fp = kpi_totals["final_pass"] + print(f" 최종 통과 (정확성) : {fp['score_1']:3d}/{evaluated} ({pct(fp['score_1']):.1f}%)") - print("난이도별:") + print() + print("난이도별 (최종 통과):") for tier in sorted(tier_results.keys()): t = tier_results[tier] - pct = t["correct"] / max(t["total"], 1) * 100 - print(f" {tier:8s}: {t['correct']:2d}/{t['total']:2d} ({pct:.0f}%)") + p = t["correct"] / max(t["total"], 1) * 100 + print(f" {tier:8s}: {t['correct']:2d}/{t['total']:2d} ({p:.0f}%)") - if results["error"] > 0: - print(f"\n 에러: {results['error']}건") + if error_count > 0: + print(f"\n 에러: {error_count}건") print(f"\n소요 시간: {elapsed:.1f}초") - print(f"평균 응답: {elapsed/max(total,1):.1f}초/질문") + if durations: + print(f"평균 응답: {sum(durations)/len(durations):.1f}초/질문") + + print(f"\n=== 챗봇 토큰 사용량 ===") + print(f" prompt : 합계 {chatbot_usage['prompt_tokens']:,} / 평균 {chatbot_usage['prompt_tokens']//max(evaluated,1):,}") + print(f" completion: 합계 {chatbot_usage['completion_tokens']:,} / 평균 {chatbot_usage['completion_tokens']//max(evaluated,1):,}") + print(f" total : 합계 {chatbot_usage['total_tokens']:,} / 평균 {chatbot_usage['total_tokens']//max(evaluated,1):,}") - # 결과 저장 result_file = DATA_DIR / "eval_result.json" with open(result_file, "w") as f: json.dump({ "total": total, - "correct": results["correct"], - "incorrect": results["incorrect"], - "error": results["error"], - "accuracy": results["correct"] / max(total, 1), + "correct": kpi_totals["final_pass"]["score_1"], + "incorrect": evaluated - kpi_totals["final_pass"]["score_1"], + "error": error_count, + "accuracy": round(kpi_totals["final_pass"]["score_1"] / max(evaluated, 1), 4), + "kpi": { + key: {**kpi_totals[key], "total": evaluated} + for key in kpi_totals + }, "tier_results": tier_results, "elapsed_seconds": elapsed, + "avg_response_seconds": (sum(durations) / len(durations)) if durations else 0, + "chatbot_token_usage": chatbot_usage, }, f, indent=2, ensure_ascii=False) print(f"\n결과 저장: {result_file}") +def _aggregate(r: dict, kpi_totals: dict, tier_results: dict, chatbot_usage: dict, + durations: list, verbose: bool): + """process_question 결과 1건을 집계합니다.""" + tier = r["tier"] + durations.append(r["duration"]) + + if tier not in tier_results: + tier_results[tier] = {"correct": 0, "total": 0} + tier_results[tier]["total"] += 1 + + if r["status"] == "error": + if verbose: + print(f"[{r['qid']}] ERROR — 서버 응답 없음") + return + + token_usage = r["token_usage"] + chatbot_usage["prompt_tokens"] += token_usage.get("promptTokens", 0) + chatbot_usage["completion_tokens"] += token_usage.get("completionTokens", 0) + chatbot_usage["total_tokens"] += token_usage.get("totalTokens", 0) + + kpi = r["kpi"] + for key in kpi_totals: + score = kpi[key] + kpi_totals[key][f"score_{score}"] += 1 + + if kpi["final_pass"] == 1: + tier_results[tier]["correct"] += 1 + + if verbose: + marker = "✓" if kpi["final_pass"] == 1 else "✗" + print(f"[{r['qid']}] {marker} ({tier}) {r['question'][:40]}...") + if kpi["final_pass"] == 0: + for k, v in r.get("reasons", {}).items(): + if kpi.get(k, 2) == 0: + print(f" [{k}=0] {str(v)[:80]}") + + if __name__ == "__main__": main() diff --git a/data/test_questions.json b/data/test_questions.json index 46491e3..c247957 100644 --- a/data/test_questions.json +++ b/data/test_questions.json @@ -1864,4 +1864,4 @@ "primary_intent": "wrong_item_received", "wall_type": "cross_language" } -] \ No newline at end of file +] diff --git a/mission/wall-report.md b/mission/wall-report.md index 3489fe3..d20e3fd 100644 --- a/mission/wall-report.md +++ b/mission/wall-report.md @@ -1,42 +1,203 @@ # Wall Report > 이 리포트는 과정을 진행하면서 부딪힌 한계를 기록하는 문서입니다. -> 완성된 답이 아니라, 경험한 문제와 생각을 솔직하게 적어주세요. +완성된 답이 아니라, 경험한 문제와 생각을 솔직하게 적어주세요. +> ## 1. 부딪힌 벽 > 구현하면서 잘 안 됐던 것, 예상과 달랐던 것을 적어주세요. +> -- +### 검색 문서의 청킹을 어떻게 할것인가? +고민 + +- 문서를 너무 크게 청킹하면 관련 없는 내용이 함께 검색되어 LLM이 불필요한 정보를 참고하는 문제가 발생 + 토큰 비용 증가 +- 너무 작게 청킹하면 맥락이 잘려 LLM이 제대로 활용하지 못하는 문제가 발생 +- 어떻게 연관이 있는 문서 끼리 청킹을 할 수 있을까? + +미션 진행 내용 + +- 마크다운 헤더(`##`)를 기준으로 주제별로 문서를 분할 +- 분할된 청킹 단위에 상위 헤더(`#`)를 접두어로 붙여 해당 청크가 어떤 카테고리에 속하는지 맥락 정보를 함께 포함 + +깨달은 점 + +- 기반 문서의 구조를 잘 구성하면 → 코드로 주제별 청킹 단위를 나누기 용이하고 → 문서 검색 품질을 향상으로 이어진다는 것을 알게됨 + +
+ +### top-k를 어떻게 설정할 것인가? + +고민 + +- 챗로그를 참고 문서 베이스에 포함하니 검색되는 top-k 문서의 대부분이 챗로그로 채워지는 상황 +- 챗로그는 질문과 유사한 형태의 문장을 많이 포함하고 있어 임베딩 유사도가 높게 나오고, 정작 신뢰성 있는 정책·FAQ 문서가 검색에서 밀리는 문제 발생 +- 챗로그를 참고 문서 베이스에 포함하니 검색되는 top-k 문서의 대부분이 챗로그로 채워지는 상황 + - 챗 로그의 질문 형태하는 형태가 높은 유사도를 가져서 더욱 신뢰성 있는 문서인 정책, FAQ 문서가 문서 검색에서 밀리는 상황 + +미션 진행 내용 + +- 평가를 반복하며 토큰 사용량 대비 정확도 증가율이 완만해지는 지점을 찾아 전체 top-k 크기 결정 +- 도큐먼트(청킹 단위) 별로 문서 카테고리를 추가하여(FAQ, POLICY, chatlog) 각각 레이어별로 top k를 설정 + - FAQ: 4 / POLICY : 4 / chatlog : 3 개씩 + +깨달은 점 + +- 단순히 top-k 수치를 올리는 것보다 문서 유형별로 검색 비율을 제어하는 것이 더 효과적 + - 정확도 72%(108/150)개 → 82%(122/150) = 10% 상승 +- 유사도 기반 검색은 문서의 형태적 유사성에 민감하기 때문에 이를 고려해서 문서 검색 플로우를 설계해야 한다. + +
+ +### 평가 기준을 어떻게 마련할 것인가? (가장 고민한 점) + +고민 +- 시스템 프롬프트에 답변 생성 제약 사항을 추가하니 기존 평가 방식으로 채점한 응답 정확도가 52.3 -> 14.7 로 오히려 떨어진 상황 +- 바뀐 채점 기준으로 같은 구현을 채점을 하니 점수가 대폭 상승함 -> 이게 바뀐 채점이 후해서 그런건지 아닌건지는 판단을 어떻게 할까? + +미션 진행 내용 + +- 만들려는 챗봇의 목표와 역할을 먼저 명확히 정의 +- 목표를 기반으로 여러 축의 KPI를 구체적으로 설계 + - 핵심 응답 / 사실성 / 거절 품질 / 두괄식 응답 / 정보 절제력 / 간결성 + - 각 KPI별 루브릭(0/1/2점)을 정의해서, 기존에 블랙박스처럼 느껴졌던 LLM 평가 과정을 각 축별로 들여다볼 수 있게 됨 + + +깨달은 점 + +- 먼저 무엇을 만들지가 명확히 정리해야 해당 결과물을 평가할 기준을 세울수 있구나 하는걸 깨달음 + (어찌보면 당연하지만, 항상 빠르게 구현하기만 급급했던것 같다…) +- 평가 지표가 모호하면 점수가 올라도 실제로 개선된 건지 알 수 없고, 개선 방향도 잡기 어렵단 것을 체감 + +
+ +### cf) 최종 KPI 테이블 + +| KPI | 설명 | 측정 방식 | 기준점 | +| --- | --- | --- | --- | +| **핵심 응답** | 질문이 요구하는 수치·조건·방법에 직접 답했는가 | **2** 질문의 핵심 정보에 완전히 답변하며 기대 답변 사실과 일치
**1** 질문에 답변했으나 핵심 정보 일부 누락 (부분 답변)
**0** 정보 완전 누락 / 무관한 답변 / 고객센터 문의 회피 | - | +| **사실성** | 실제 답변이 기대 답변과 모순되는 내용이 없는가 | **2** 수치·기간·조건·인과관계 모두 기대 답변과 일치
**1** 질문에 대한 핵심 답변은 맞으나 부가 설명이 기대 답변과 다른 내용 포함
**0** 핵심 답변 자체가 기대 답변과 충돌 (수치 오류 / 조건 반전 / 인과관계 반전 / 없는 정보 생성) | - | +| **거절 품질** | 거절이 필요한 상황에서 적절히 거절했는가,
불필요한 거절은 없는가 | **2** 거절 없음 (정상 답변)
**1** 기대 답변이 없고 실제 답변도 적절히 거절
**0** 기대 답변이 있는데 실제 답변이 거절 | ≥ 90% | +| **두괄식 응답** *(보조)* | 첫 문장에 핵심 답변이 있는가 | **1** 첫 문장에 핵심 정보 직접 전달 또는 거절 의사 명확히 표현
**0** 서론·공감·확인 문구로 시작 | ≥ 80% | +| **정보 절제력** *(보조)* | 부가 설명이 과도하지 않은가 | **1** 직접 답변 외 부가 정보 1개 이하
**0** 직접 답변 외 부가 정보 2개 이상 | ≥ 80% | +| **간결성** *(보조)* | 답변이 불필요하게 장황하지 않은가 | **1** 200자 이하
**0** 200자 초과 | ≥ 80% | +| **최종 통과** | 정확성 기준 최종 판정 | 거절 품질 = 1 → **통과**
거절 품질 = 0 → **탈락**
거절 품질 = 2 → 핵심 응답 + 사실성 = 4 → 통과 | ≥ 80% | + +--- + +
+
## 2. 해결하지 못한 것 > 시도했지만 결국 해결 못한 문제가 있다면 적어주세요. +> + +특정 질문에서 틀린 답변이 나올 때마다 시스템 프롬프트에 예외 규칙을 추가하는 방식으로 대응했는데, 이 방식이 근본적인 해결책인지 의문이 남음 -- +- 케이스별로 규칙을 추가하다 보면 프롬프트가 누더기 골렘처럼 쌓이고, + 새로운 규칙이 기존 규칙과 충돌하거나 의도하지 않은 케이스에 적용해 답변하는 부작용이 생김 + → 실제로 고객 개인 정보 거절 규칙을 추가했더니, 탈퇴 방법·비밀번호 변경처럼 일반적인 절차 질문에도 거절하는 문제가 발생 + + → 결국 시스템 프롬프트 수정은 근본적인 원인을 해결하지 못한다고 느낌 + + +
+ +문서 검색 품질을 올리려면 어떻게 해야 할까? + +- 관련 문서가 제대로 검색되면 시스템 프롬프트에 예외 규칙을 쌓지 않아도 LLM이 문서를 근거로 정확한 답변을 생성할 수 있지 않을까? +- 문서 검색 품질을 올리기 위한 시도는 다음에서 그침 + - 청킹 전략 개선 + - 문서 레이어별 (FAQ, chatlog, policy) top-k 보장 + + +
+ +평가 스크립트를 새로 정의한 KPI 기준에 맞게 수정하였더니 정확도 지표가 크게 상승한 상황 + +내가 목표로 한 챗봇에 맞는 평가 기준을 마련했으니 어찌 보면 자연스러운 결과이긴 하지만, +정확한 평가가 이루어 진 건지는 불신이 남음 + +- 이를 검증하려면 결국 사람이 직접 샘플을 보고 평가해보는 과정이 필요하다는 생각이 들었습니다. + + → 혹은 이런 방식 말고도 검증할 수 있는 방법이 있을까요? + +- 추가로 힌트를 보니 + + `Spring Boot Test + MockMvc — API 동작 자체를 테스트 코드로 검증하는 방법도 조사해보세요` + 라고 적혀 있었는데, 이건 어떤걸 해보길 의도하신건지 궁금합니다! + + +
## 3. 정확도 측정 결과 > 테스트 질문 150개로 측정한 정확도를 기록해주세요. +> | 난이도 | 정확도 | 비고 | -|--------|--------|------| -| easy | | | -| medium | | | -| hard | | | +| --- | --- | --- | +| easy | 29/30 | 97% | +| medium | 87/94 | 93% | +| hard | 24/26 | 92% | + +### KPI 지표 + +| 핵심 KPI | 완전 (2점) | 부분 (1점) | 실패 (0점) | +| --- | --- | --- | --- | +| **거절 품질** | 146 (97%) | 0 (0%) | 4 (3%) | +| **핵심 응답** | 142 (95%) | 2 (1%) | 6 (4%) | +| **사실성** | 143 (95%) | 5 (3%) | 2 (1%) | + +| 보조 KPI | 통과 (1점) | 실패 (0점) | +| --- | --- | --- | +| **두괄식 응답** *(보조)* | 143 (95.3%) | 7 (4.7%) | +| **정보 절제력** *(보조)* | 146 (97.3%) | 4 (2.7%) | +| **간결성 ≤200자** *(보조)* | 149 (99.3%) | 1 (0.7%) | +| 최종 결과 | 비고 | +| --- | --- | +| **최종 통과 (정확성)** | **140/150 (93.3%)** | + +
## 4. 왜 그런 결과가 나왔는지 > 정확도가 낮은 난이도의 질문을 몇 개 살펴보고, 왜 틀렸는지 분석해주세요. +> + +실제 가상계좌 결제는 불가능 하지만 아예 반대로 대답하는 상황 존재 + +```bash +curl -X POST http://localhost:8080/api/chat \ + -H "Content-Type: application/json" \ + -d '{"question": "가상계좌 결제 된다고 봤는데 지원이 되나요?"}' +{"answer":"네, 고객님께서는 계좌이체 방식을 통해 결제하실 수 있으며, 가상계좌 결제도 지원됩니다.","tokenUsage":{"promptTokens":1814,"completionTokens":30,"totalTokens":1844}}% +``` -- +- 로그를 보았을때, 관련 문서가 잘 검색되어 LLM에게 전달되었음에도 가상계좌가 된다고 판단한 상황 +왜 그랬을까? + +- 검색된 문서 중 챗로그에 "계좌이체 지원합니다"라는 문장이 있었고, LLM이 "계좌이체"와 "가상계좌"를 같은 개념으로 혼동하여 지원된다고 답변한 것으로 보임 + + (해당 챗 로그가 가장 유사도가 높게 검색되었음 + + +
## 5. 개선하고 싶은 것 > 시간이 더 있었다면 시도해보고 싶은 개선점을 적어주세요. +> + +사용자 질문을 RAG 검색 전에 LLM으로 전처리 하는 과정을 추가해 보고 싶다. + +- 줄임말 등 을 사용하면, 관련 문서 검색에 있어 유사도가 낮게 측정되어, + 분명 해당 내용이 있는 문서임에도 해당 문서를 검색하지 못하는 상황이 왕왕 있었다. +- llm을 한 번 더 호출하는 만큼 응답 시간의 지연이 발생할수 있고, 토큰의 사용량이 높아질 텐데 이 트레이드 오프를 고려한 기준을 세우는 것이 중요할 것 같다. -- diff --git a/src/main/java/com/cholog/bootcamp/chatbot/application/ChatbotService.java b/src/main/java/com/cholog/bootcamp/chatbot/application/ChatbotService.java new file mode 100644 index 0000000..cbc1833 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/application/ChatbotService.java @@ -0,0 +1,79 @@ +package com.cholog.bootcamp.chatbot.application; + +import com.cholog.bootcamp.chatbot.application.dto.ChatbotResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; + +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 java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatbotService { + + private static final int FAQ_TOP_K = 4; + private static final int POLICY_TOP_K = 4; + private static final int CHATLOG_TOP_K = 3; + private static final String PROMPT = """ + [참고 문서] + %s + + [고객 질문] + %s + """; + + private final ChatClient chatClient; + private final VectorStore vectorStore; + + public ChatbotResult chat(String question) { + String context = searchRelevantDocuments(question); + String userMessage = PROMPT.formatted(context, question); + + ChatResponse aiResponse = chatClient.prompt() + .user(userMessage) + .call() + .chatResponse(); + return ChatbotResult.of(aiResponse); + } + + private String searchRelevantDocuments(String question) { + List docs = new java.util.ArrayList<>(); + docs.addAll(searchByLayer(question, "faq", FAQ_TOP_K)); + docs.addAll(searchByLayer(question, "policy", POLICY_TOP_K)); + docs.addAll(searchByLayer(question, "chatlog", CHATLOG_TOP_K)); + + loggingSearchedDocs(question, docs); + + return docs.stream() + .map(Document::getText) + .collect(Collectors.joining("\n\n")); + } + + private List searchByLayer(String question, String layer, int topK) { + return vectorStore.similaritySearch( + SearchRequest.builder() + .query(question) + .topK(topK) + .filterExpression("layer == '" + layer + "'") + .build() + ); + } + + private static void loggingSearchedDocs(String question, List docs) { + log.info("=== [RAG] 검색된 문서 ({}개) for: {} ===", docs.size(), question); + for (int i = 0; i < docs.size(); i++) { + Document doc = docs.get(i); + String preview = doc.getText().substring(0, Math.min(120, doc.getText().length())).replace("\n", " "); + log.info("[{}] metadata={} | text={}", i + 1, doc.getMetadata(), preview); + } + } + +} diff --git a/src/main/java/com/cholog/bootcamp/chatbot/application/dto/ChatbotResult.java b/src/main/java/com/cholog/bootcamp/chatbot/application/dto/ChatbotResult.java new file mode 100644 index 0000000..f727f1e --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/application/dto/ChatbotResult.java @@ -0,0 +1,22 @@ +package com.cholog.bootcamp.chatbot.application.dto; + +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; + +public record ChatbotResult( + String answer, + long promptTokens, + long completionTokens, + long totalTokens +) { + + public static ChatbotResult of(ChatResponse response) { + Usage usage = response.getMetadata().getUsage(); + return new ChatbotResult( + response.getResult().getOutput().getText(), + usage.getPromptTokens(), + usage.getCompletionTokens(), + usage.getTotalTokens() + ); + } +} diff --git a/src/main/java/com/cholog/bootcamp/chatbot/infrastructure/DocumentLoader.java b/src/main/java/com/cholog/bootcamp/chatbot/infrastructure/DocumentLoader.java new file mode 100644 index 0000000..5232dc4 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/infrastructure/DocumentLoader.java @@ -0,0 +1,99 @@ +package com.cholog.bootcamp.chatbot.infrastructure; + +import com.cholog.bootcamp.chatbot.util.ChatlogParser; +import com.cholog.bootcamp.chatbot.util.MarkdownHeadingSplitter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; +import org.springframework.ai.reader.TextReader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DocumentLoader { + + private static final String FAQ_PATTERN = "file:data/layer1_faq/*.md"; + private static final String POLICY_PATTERN = "file:data/layer2_policies/current/*.md"; + private static final String CHATLOG_PATTERN = "file:data/layer3_chatlogs/*.jsonl"; + private static final String LAYER_FAQ = "faq"; + private static final String LAYER_POLICY = "policy"; + + private final ChatlogParser chatlogParser; + private final PathMatchingResourcePatternResolver resolver = + new PathMatchingResourcePatternResolver(); + private final MarkdownHeadingSplitter faqSplitter = new MarkdownHeadingSplitter("###"); + private final MarkdownHeadingSplitter policySplitter = new MarkdownHeadingSplitter("##"); + + public List loadFaq() { + return load(FAQ_PATTERN, LAYER_FAQ, faqSplitter); + } + + public List loadPolicies() { + return load(POLICY_PATTERN, LAYER_POLICY, policySplitter); + } + + public List loadChatlogs() { + try { + Resource[] resources = resolver.getResources(CHATLOG_PATTERN); + if (resources.length == 0) { + log.warn("챗로그 파일 없음"); + return List.of(); + } + + List result = new ArrayList<>(); + for (Resource resource : resources) { + result.addAll(chatlogParser.parse(resource)); + } + + log.info("챗로그 로드 완료: 총 {}개 청크", result.size()); + return result; + + } catch (IOException e) { + throw new IllegalStateException("챗로그 로딩 실패", e); + } + } + + private List load(String pattern, String layer, MarkdownHeadingSplitter splitter) { + try { + Resource[] resources = resolver.getResources(pattern); + + if (resources.length == 0) { + log.warn("문서 없음: layer={}", layer); + return List.of(); + } + + List result = new ArrayList<>(); + for (Resource resource : resources) { + result.addAll(toDocuments(resource, layer, splitter)); + } + + log.info("문서 로드 완료: layer={}, 총 {}개 청크", layer, result.size()); + return result; + + } catch (IOException e) { + throw new IllegalStateException("문서 로딩 실패: pattern=" + pattern, e); + } + } + + private List toDocuments(Resource resource, String layer, MarkdownHeadingSplitter splitter) { + List raw = new TextReader(resource).get(); + List chunks = raw.stream() + .flatMap(doc -> splitter.split(doc).stream()) + .toList(); + chunks.forEach(doc -> attachMetadata(doc, resource, layer)); + log.debug("파일 청킹: {} → {}개 청크", resource.getFilename(), chunks.size()); + return chunks; + } + + private void attachMetadata(Document doc, Resource resource, String layer) { + doc.getMetadata().put("source", resource.getFilename()); + doc.getMetadata().put("layer", layer); + } +} diff --git a/src/main/java/com/cholog/bootcamp/chatbot/infrastructure/VectorStoreInitializer.java b/src/main/java/com/cholog/bootcamp/chatbot/infrastructure/VectorStoreInitializer.java new file mode 100644 index 0000000..186c07b --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/infrastructure/VectorStoreInitializer.java @@ -0,0 +1,42 @@ +package com.cholog.bootcamp.chatbot.infrastructure; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class VectorStoreInitializer implements ApplicationRunner { + + private final DocumentLoader documentLoader; + private final VectorStore vectorStore; + + @Override + public void run(ApplicationArguments args) { + List faqDocs = documentLoader.loadFaq(); + List policyDocs = documentLoader.loadPolicies(); + List chatlogDocs = documentLoader.loadChatlogs(); + + List all = new ArrayList<>(); + all.addAll(faqDocs); + all.addAll(policyDocs); + all.addAll(chatlogDocs); + + if (all.isEmpty()) { + log.warn("적재할 문서 없음. data/ 폴더 확인 필요"); + return; + } + + vectorStore.add(all); + log.info("임베딩 완료: 총 {}개 (faq={}, policy={}, chatlog={})", + all.size(), faqDocs.size(), policyDocs.size(), chatlogDocs.size()); + } +} diff --git a/src/main/java/com/cholog/bootcamp/chatbot/presentation/ChatbotController.java b/src/main/java/com/cholog/bootcamp/chatbot/presentation/ChatbotController.java new file mode 100644 index 0000000..4549525 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/presentation/ChatbotController.java @@ -0,0 +1,27 @@ +package com.cholog.bootcamp.chatbot.presentation; + +import com.cholog.bootcamp.chatbot.application.ChatbotService; +import com.cholog.bootcamp.chatbot.application.dto.ChatbotResult; +import com.cholog.bootcamp.chatbot.presentation.dto.ChatbotRequest; +import com.cholog.bootcamp.chatbot.presentation.dto.ChatbotResponse; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +public class ChatbotController { + + private final ChatbotService chatbotService; + + @PostMapping + public ResponseEntity chat(@RequestBody ChatbotRequest request) { + ChatbotResult chatbotResult = chatbotService.chat(request.question()); + return ResponseEntity.ok().body(ChatbotResponse.of(chatbotResult)); + } + +} diff --git a/src/main/java/com/cholog/bootcamp/chatbot/presentation/dto/ChatbotRequest.java b/src/main/java/com/cholog/bootcamp/chatbot/presentation/dto/ChatbotRequest.java new file mode 100644 index 0000000..874b7c5 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/presentation/dto/ChatbotRequest.java @@ -0,0 +1,6 @@ +package com.cholog.bootcamp.chatbot.presentation.dto; + +public record ChatbotRequest( + String question +) { +} diff --git a/src/main/java/com/cholog/bootcamp/chatbot/presentation/dto/ChatbotResponse.java b/src/main/java/com/cholog/bootcamp/chatbot/presentation/dto/ChatbotResponse.java new file mode 100644 index 0000000..d7df049 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/presentation/dto/ChatbotResponse.java @@ -0,0 +1,27 @@ +package com.cholog.bootcamp.chatbot.presentation.dto; + +import com.cholog.bootcamp.chatbot.application.dto.ChatbotResult; + +public record ChatbotResponse( + String answer, + TokenUsage tokenUsage +) { + + public static ChatbotResponse of(ChatbotResult result) { + return new ChatbotResponse( + result.answer(), + new TokenUsage( + result.promptTokens(), + result.completionTokens(), + result.totalTokens() + ) + ); + } + + record TokenUsage( + long promptTokens, + long completionTokens, + long totalTokens + ) { + } +} diff --git a/src/main/java/com/cholog/bootcamp/chatbot/util/ChatlogParser.java b/src/main/java/com/cholog/bootcamp/chatbot/util/ChatlogParser.java new file mode 100644 index 0000000..73939b2 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/util/ChatlogParser.java @@ -0,0 +1,88 @@ +package com.cholog.bootcamp.chatbot.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +public class ChatlogParser { + + private static final String LAYER_CHATLOG = "chatlog"; + private static final String ACCURACY_CORRECT = "correct"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public List parse(Resource resource) throws IOException { + List result = new ArrayList<>(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + Document doc = parseLine(line, resource.getFilename()); + if (doc != null) result.add(doc); + } + } + + log.debug("챗로그 파싱: {} → {}개 청크", resource.getFilename(), result.size()); + return result; + } + + private Document parseLine(String line, String filename) throws IOException { + JsonNode node = objectMapper.readTree(line); + if (!ACCURACY_CORRECT.equals(node.path("agent_accuracy").asText())) return null; + + String text = buildText(node); + if (text == null) return null; + + return toDocument(text, filename); + } + + private Document toDocument(String text, String filename) { + Document doc = new Document(text); + doc.getMetadata().put("layer", LAYER_CHATLOG); + doc.getMetadata().put("source", filename); + return doc; + } + + private String buildText(JsonNode node) { + StringBuilder sb = new StringBuilder(); + appendTags(sb, node); + boolean hasAgentTurn = appendTurns(sb, node); + return hasAgentTurn ? sb.toString().trim() : null; + } + + private void appendTags(StringBuilder sb, JsonNode node) { + sb.append("[예시 대화]\n"); + List tags = new ArrayList<>(); + node.path("tags").forEach(tag -> tags.add(tag.asText())); + if (!tags.isEmpty()) { + sb.append("[태그: ").append(String.join(", ", tags)).append("]\n"); + } + } + + private boolean appendTurns(StringBuilder sb, JsonNode node) { + boolean hasAgentTurn = false; + for (JsonNode turn : node.path("turns")) { + String text = turn.path("text").asText(); + + String role = turn.path("role").asText(); + String prefix = "customer".equals(role) ? "고객" : "상담원"; + sb.append(prefix).append(": ").append(text).append("\n"); + + if ("agent".equals(role)) hasAgentTurn = true; + } + return hasAgentTurn; + } + +} \ No newline at end of file diff --git a/src/main/java/com/cholog/bootcamp/chatbot/util/MarkdownHeadingSplitter.java b/src/main/java/com/cholog/bootcamp/chatbot/util/MarkdownHeadingSplitter.java new file mode 100644 index 0000000..9f1dfe3 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/chatbot/util/MarkdownHeadingSplitter.java @@ -0,0 +1,34 @@ +package com.cholog.bootcamp.chatbot.util; + +import org.springframework.ai.document.Document; + +import java.util.Arrays; +import java.util.List; + +public class MarkdownHeadingSplitter { + + private final String heading; + + public MarkdownHeadingSplitter(String heading) { + this.heading = heading; + } + + public List split(Document document) { + String text = document.getText(); + String title = extractTitle(text); + + String[] sections = text.split("(?m)^(?=" + heading + " )"); + return Arrays.stream(sections) + .map(String::strip) + .filter(s -> s.startsWith(heading)) + .map(s -> new Document(title + "\n" + s)) + .toList(); + } + + private String extractTitle(String text) { + return text.lines() + .filter(line -> line.startsWith("# ")) + .findFirst() + .orElse(""); + } +} \ No newline at end of file diff --git a/src/main/java/com/cholog/bootcamp/config/AiConfig.java b/src/main/java/com/cholog/bootcamp/config/AiConfig.java new file mode 100644 index 0000000..af923a5 --- /dev/null +++ b/src/main/java/com/cholog/bootcamp/config/AiConfig.java @@ -0,0 +1,61 @@ +package com.cholog.bootcamp.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AiConfig { + + private static final String SYSTEM_PROMPT = """ + 당신은 초록 코퍼레이션의 고객센터 상담원입니다. [참고 문서]만을 근거로 답변하세요. + + [답변 형식] 두괄식으로 작성하세요. 레이블("1단계", "2단계" 등)은 절대 출력하지 마세요. + - 핵심 정답을 한두 문장으로 먼저 구체적으로 답변하세요. + - 밀접하게 연관된 부가 정보(예외/조건/팁)가 있으면 최대 1가지만 추가하고, 없으면 생략하세요. + + [답변 전 내부 처리] + 1. 비격식 표현을 표준어로 해석 후 핵심 주제를 파악하세요. + 예) "비번 어케바꿈?" → 비밀번호 변경 방법 / "반품 며칠까지?" → 반품 신청 기간 + 2. [참고 문서]에서 해당 주제를 직접 다루는 섹션을 찾아 그것만 근거로 답변하세요. + + [특수 상황] + - 고객이 틀린 정보를 전제로 물을 때: 문서 기준으로 정중히 정정하세요. + 예) Q: "[고객의 틀린 전제] 아닌가요?" → A: "현행 정책에 따르면 [참고 문서의 정확한 팩트]입니다." + - 고객이 "~라고 들었는데 맞나요?" 형태로 확인 요청 시: 현재 문서 기준을 안내하세요. + 예) Q: "[과거 정보] 아닌가요?" → A: "현재 기준으로는 [참고 문서의 최신 기준]입니다." + - 과거 사례/다른 고객 경험 질문 시: 문서에 명시된 보상·처리 기준을 안내하세요. + + [개인 계정 정보] 포인트 잔액, 주문 내역, 배송 조회 등 특정 고객의 계정에 종속된 정보는 이 챗봇이 시스템상 접근할 수 없습니다. + 이 경우 "고객님의 [정보]는 마이페이지에서 직접 확인하실 수 있습니다."라고 안내하세요. + ※ 단, 탈퇴 방법, 비밀번호 변경 방법 등 일반적인 절차를 묻는 질문은 사용자 개인정보에 대한 질문이 아니므로, + 문서 기준으로 방법을 안내하세요. + + [예시 대화 처리] [참고 문서] 중 [예시 대화]로 표시된 항목은 과거 상담 사례입니다. + 예시 대화 속 고객의 주문 상태·날짜 등 개별 상황은 현재 고객과 무관합니다. + 상담원의 답변 방식과 정책 안내 패턴만 참고하고, 예시 고객의 상황을 현재 고객에게 적용하지 마세요. + + [거절 기준] [참고 문서]에 관련 내용이 전혀 없는 경우에만 아래 문구를 사용하세요. + 관련 내용이 있다면 반드시 그것을 근거로 답변하세요. + 거절 문구: "죄송합니다. 요청하신 정보는 정확한 안내가 어렵습니다. 고객센터로 문의해 주세요." + + [어조] 고객이 반말·구어체를 사용해도 항상 친절하고 정중한 표준어(~입니다, ~합니다)를 유지하세요. + 불편을 겪은 고객에게는 공감 표현("불편을 드려 죄송합니다")을 자연스럽게 한 문장 추가하세요. + """; + + @Bean + public ChatClient chatClient(ChatClient.Builder builder) { + return builder + .defaultSystem(SYSTEM_PROMPT) + .build(); + } + + @Bean + public VectorStore vectorStore(EmbeddingModel embeddingModel) { + return SimpleVectorStore.builder(embeddingModel).build(); + } + +}