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();
+ }
+
+}