From 8fa72981501b4cde20e3ba5c7d70a646aadac19f Mon Sep 17 00:00:00 2001 From: Claude-51124 Date: Tue, 14 Oct 2025 13:01:14 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20Coldmail=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EB=B0=8F=20KoBERT+LLM+Bayes=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=A6=AC=EB=93=9C=20=EA=B0=9C=EC=84=A0=20=EB=B0=A9?= =?UTF-8?q?=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 9시 5분 IR deck 미전송 원인 분석 완료 - 근본 원인: 정규식 토큰화의 한국어 형태소 분리 실패 - 해결책: 3단계 하이브리드 아키텍처 제안 1단계: KoBERT 임베딩 (고속 필터링, 90%) 2단계: Gemini LLM (정밀 분류, 10%) 3단계: Naive Bayes (실시간 학습) - 구현 계획 및 예상 시간(12h) 포함 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ...aude_coldmail_filter_tokenization_issue.md | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 troubleshooting/251014_claude_coldmail_filter_tokenization_issue.md diff --git a/troubleshooting/251014_claude_coldmail_filter_tokenization_issue.md b/troubleshooting/251014_claude_coldmail_filter_tokenization_issue.md new file mode 100644 index 0000000..f2887cd --- /dev/null +++ b/troubleshooting/251014_claude_coldmail_filter_tokenization_issue.md @@ -0,0 +1,375 @@ +# Coldmail 필터 토큰화 문제 및 하이브리드 개선 방안 + +**날짜**: 2025-10-14 +**작성자**: Claude (51124 서버 전담) +**관련 파일**: `rb8001/app/services/coldmail_filter.py`, `rb8001/app/scheduler/jobs/coldmail_briefing.py` + +--- + +## 문제 상황 + +### 발단 +9시 5분 Coldmail Daily Briefing 실행 시 IR deck 이메일이 Slack에 전송되지 않음. + +### 조사 결과 +- 올굿즈컴퍼니 IR deck (`251013_올굿즈컴퍼니_회사소개서.pdf`): coldmail 확률 30.35% +- 빅웨이브 IR 행사 안내: coldmail 확률 7.65% +- 투자제안서 검토 요청: coldmail 확률 28.38% +- **모두 threshold 70% 미달로 필터링 실패** + +--- + +## 근본 원인: 토큰화 알고리즘 한계 + +### 현재 구현 (coldmail_filter.py:44-52) +```python +def tokenize(text: str) -> List[str]: + import re + tokens = [token.strip() for token in re.split(r'[\s\W]+', text) if token.strip()] + return tokens +``` + +### 문제점 + +**1. 복합명사 분해 실패** +- `"회사소개서"` → 하나의 토큰 (형태소 분리 안 됨) +- DB에는 "회사", "소개서"로 학습되어 있으나 매칭 실패 + +**2. 조사 분리 실패** +- `"ir에"` → 하나의 토큰 +- DB의 "ir" (coldmail 90.9%)와 매칭 실패 + +**3. 실제 테스트 결과** +``` +제목: "251013_올굿즈컴퍼니_회사소개서.pdf" +토큰: ['251013_올굿즈컴퍼니_회사소개서', 'pdf'] +→ "회사소개서" 단어 매칭 실패 + +제목: "2025 빅웨이브(BiiG WAVE) 하반기 IR에 여러분을 초대합니다.(10/23, 코엑스)" +토큰: ['2025', '빅웨이브', 'biig', 'wave', '하반기', 'ir에', '여러분을', '초대합니다', '10', '23', '코엑스'] +→ "ir" 단어 매칭 실패 +``` + +### 학습 데이터는 정상 +``` +총 단어 수: 56 +총 coldmail 카운트: 347 +총 normal 카운트: 210 + +주요 키워드: +- ir: cold 90.9% +- 투자: cold 90.9% +- 투자유치: cold 100% +- 제안: cold 80% +``` + +--- + +## 개선 방안: KoBERT + LLM + Naive Bayes 3단계 하이브리드 + +### 설계 철학 +- **속도**: KoBERT 임베딩으로 1차 필터링 (빠름) +- **정확도**: Gemini LLM으로 경계선 케이스 판단 (높음) +- **학습**: Naive Bayes로 실시간 피드백 반영 (지속 개선) + +### 아키텍처 + +``` +이메일 수신 + ↓ +[1단계] KoBERT 임베딩 필터 (고속) + - skill-embedding (8515) 활용 + - coldmail/normal 클러스터와 cosine similarity + - threshold 0.6 이상만 통과 (약 90% 필터링) + ↓ +[2단계] Gemini LLM 분류 (정밀) + - 1단계 통과한 이메일만 처리 (API 비용 10%) + - Zero-shot classification + - Prompt: "다음 이메일이 투자/제안 관련 coldmail인지 판단하고 이유를 설명하시오" + - 결과: True/False + 설명 + ↓ +[3단계] Naive Bayes 학습 (피드백) + - Slack 버튼 피드백 → DB 즉시 반영 + - 1단계 threshold 동적 조정 + - 토큰 통계 업데이트 + ↓ +Slack Lists 등록 + 피드백 버튼 +``` + +### 구현 계획 + +#### Phase 1: KoBERT 임베딩 필터 (우선순위 높음) +**파일**: `rb8001/app/services/coldmail_embedding_filter.py` (신규) + +```python +async def create_embedding(text: str) -> List[float]: + """skill-embedding 서비스로 임베딩 생성""" + async with aiohttp.ClientSession() as session: + async with session.post( + "http://localhost:8515/embed", + json={"texts": [text]} + ) as resp: + data = await resp.json() + return data["embeddings"][0] + +async def calculate_similarity( + email_embedding: List[float], + cluster_embeddings: List[List[float]] +) -> float: + """Cosine similarity 계산""" + from numpy import dot + from numpy.linalg import norm + + similarities = [ + dot(email_embedding, cluster_emb) / (norm(email_embedding) * norm(cluster_emb)) + for cluster_emb in cluster_embeddings + ] + return max(similarities) + +async def is_coldmail_by_embedding( + subject: str, + sender_email: str, + threshold: float = 0.6 +) -> Tuple[bool, float]: + """임베딩 기반 coldmail 판단""" + # 1. 이메일 임베딩 생성 + text = f"{subject} from {sender_email}" + email_emb = await create_embedding(text) + + # 2. coldmail 클러스터와 유사도 계산 + coldmail_clusters = await load_coldmail_clusters() # DB 또는 파일 + similarity = await calculate_similarity(email_emb, coldmail_clusters) + + return (similarity > threshold, similarity) +``` + +**DB 테이블**: `coldmail_embedding_clusters` +```sql +CREATE TABLE coldmail_embedding_clusters ( + id SERIAL PRIMARY KEY, + embedding VECTOR(768), -- pgvector extension + label VARCHAR(10), -- 'coldmail' or 'normal' + example_subject TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +#### Phase 2: Gemini LLM 분류기 (우선순위 중간) +**파일**: `rb8001/app/services/coldmail_llm_classifier.py` (신규) + +```python +async def classify_by_llm( + subject: str, + sender_email: str, + body_snippet: str = "" +) -> Tuple[bool, str]: + """Gemini LLM으로 coldmail 분류""" + from app.llm.gemini_handler import get_gemini_model + + prompt = f"""다음 이메일이 투자/제안/협업 관련 coldmail인지 판단하시오. + +제목: {subject} +발신자: {sender_email} +본문 일부: {body_snippet[:200]} + +판단 기준: +- coldmail: 투자 유치, IR, 사업 제안, 파트너십 제안, VC 소개 등 +- normal: 회의 초대, 업무 요청, 뉴스레터, 광고 등 + +응답 형식: +{{ + "is_coldmail": true/false, + "reason": "판단 이유 (한 줄)" +}} +""" + + model = get_gemini_model("gemini-2.5-flash-lite") + response = await model.generate_content_async(prompt) + + # JSON 파싱 (마크다운 블록 제거) + import json + import re + text = response.text + text = re.sub(r'```json\s*|\s*```', '', text).strip() + result = json.loads(text) + + return (result["is_coldmail"], result["reason"]) +``` + +#### Phase 3: 3단계 통합 (우선순위 높음) +**파일**: `rb8001/app/services/coldmail_hybrid_filter.py` (신규) + +```python +async def hybrid_coldmail_filter( + subject: str, + sender_email: str, + body_snippet: str = "" +) -> Tuple[bool, Dict[str, Any]]: + """3단계 하이브리드 coldmail 필터""" + + # 1단계: KoBERT 임베딩 (빠른 필터링) + is_candidate, similarity = await is_coldmail_by_embedding(subject, sender_email) + + if not is_candidate: + return False, { + "stage": "embedding", + "similarity": similarity, + "reason": "임베딩 유사도 낮음" + } + + # 2단계: Gemini LLM (정밀 분류) + is_coldmail, llm_reason = await classify_by_llm(subject, sender_email, body_snippet) + + if not is_coldmail: + return False, { + "stage": "llm", + "similarity": similarity, + "reason": llm_reason + } + + # 3단계: Naive Bayes (보조 검증 + 학습) + nb_score = await calculate_naive_bayes_probability(subject, sender_email) + + return True, { + "stage": "hybrid", + "embedding_similarity": similarity, + "llm_reason": llm_reason, + "naive_bayes_score": nb_score + } + +async def update_from_feedback( + subject: str, + sender_email: str, + is_coldmail_feedback: bool +): + """피드백으로 3가지 모델 모두 업데이트""" + # 1. 임베딩 클러스터 업데이트 + embedding = await create_embedding(f"{subject} from {sender_email}") + label = "coldmail" if is_coldmail_feedback else "normal" + await save_embedding_cluster(embedding, label, subject) + + # 2. Naive Bayes 업데이트 (기존) + await update_naive_bayes_classifier(subject, sender_email, is_coldmail_feedback) + + # 3. LLM은 업데이트 불필요 (zero-shot) +``` + +#### Phase 4: coldmail_briefing.py 통합 +**파일**: `rb8001/app/scheduler/jobs/coldmail_briefing.py:121-136` + +```python +# 기존 코드 교체 +from app.services.coldmail_hybrid_filter import hybrid_coldmail_filter + +coldmail_candidates = [] +for email in emails: + subject = email.get("subject", "") + sender_raw = email.get("from", "") + sender = sender_raw.get("email", "") if isinstance(sender_raw, dict) else sender_raw + + # 3단계 하이브리드 필터 적용 + is_coldmail, details = await hybrid_coldmail_filter(subject, sender) + + if is_coldmail: + email["coldmail_details"] = details # 분류 정보 저장 + coldmail_candidates.append(email) + + logger.info( + f"Coldmail detected: '{subject}' from {sender} | " + f"similarity={details['embedding_similarity']:.3f}, " + f"reason={details.get('llm_reason', 'N/A')}" + ) +``` + +--- + +## 기술 선택 근거 + +### KoBERT vs 한국어 형태소 분석기 +- **KoBERT 선택 이유**: + - 의미 기반 매칭 (동의어, 유사 표현 자동 처리) + - skill-embedding (8515) 기존 인프라 활용 + - 형태소 분석기 불필요 (mecab, konlpy 등 설치 불필요) + - 2024년 연구에서 F1 0.99 입증 + +### Gemini vs DistilBERT Fine-tuning +- **Gemini 선택 이유**: + - 즉시 사용 가능 (학습 데이터 수집/라벨링 불필요) + - 설명 가능성 (LLM이 판단 이유 제공) + - 기존 Gemini API 인프라 활용 + - 1차 필터 후 10% 케이스만 처리 → 비용 감당 가능 + +### Naive Bayes 유지 이유 +- **실시간 학습 가능**: Slack 버튼 피드백 → DB 즉시 반영 +- **빠른 추론**: 임베딩/LLM 장애 시 백업 +- **토큰 통계 활용**: 1단계 threshold 동적 조정에 활용 가능 + +--- + +## 예상 효과 + +### 성능 개선 +- **재현율(Recall)**: 30% → 95% (IR deck 놓치지 않음) +- **정밀도(Precision)**: 유지 또는 개선 (LLM 정밀 판단) +- **응답 속도**: 평균 500ms (1차 100ms + 2차 400ms) + +### 비용 절감 +- **Gemini API**: 전체 이메일의 10%만 호출 (1차 필터 효과) +- **DB 부하**: 임베딩 클러스터 캐싱으로 최소화 + +### 운영 효율 +- **설명 가능성**: LLM이 판단 이유 제공 → 피드백 품질 향상 +- **지속 학습**: Slack 피드백으로 3가지 모델 모두 개선 + +--- + +## 구현 일정 + +| Phase | 작업 | 예상 시간 | 우선순위 | +|-------|------|----------|---------| +| 1 | KoBERT 임베딩 필터 구현 | 4시간 | 높음 | +| 2 | Gemini LLM 분류기 구현 | 2시간 | 중간 | +| 3 | 3단계 하이브리드 통합 | 3시간 | 높음 | +| 4 | coldmail_briefing.py 통합 | 1시간 | 높음 | +| 5 | 테스트 및 피드백 루프 검증 | 2시간 | 높음 | + +**총 예상 시간**: 12시간 (1.5일) + +--- + +## 테스트 케이스 + +### 성공 케이스 (coldmail로 분류되어야 함) +1. `"251013_올굿즈컴퍼니_회사소개서.pdf"` from `gomtose@naver.com` +2. `"2025 빅웨이브 하반기 IR에 여러분을 초대합니다"` from `biigwave@ccei.kr` +3. `"혁신소상공인 투자연계지원 관련 투자제안서 검토 요청"` from `silkro2009@silkro.org` + +### 실패 케이스 (normal로 분류되어야 함) +1. `"[KBAN] 전문기관을 사칭한 피싱 메일 유포 주의 안내"` from `jointips@kban.or.kr` +2. `"[SSG.COM] 주문하신 상품내역입니다"` from `ssgadmin@ssg.com` +3. `"회의 일정 안내"` from `team@company.com` + +--- + +## 교훈 + +### 단순 정규식 토큰화의 한계 +- 한국어는 교착어 특성상 조사/어미 분리 필수 +- 복합명사 분해 없이는 키워드 매칭 실패 +- 교훈: 의미 기반 접근(임베딩)이 토큰 기반보다 강건함 + +### 단일 모델의 위험성 +- Naive Bayes만으로는 형태소 문제 해결 불가 +- LLM만으로는 학습 불가능 + 비용 과다 +- 교훈: 하이브리드 접근으로 각 모델의 장점 활용 + +### 피드백 루프의 중요성 +- 모델 정확도는 사용자 피드백으로 지속 개선 +- Slack 버튼 → 3가지 모델 모두 업데이트 +- 교훈: 실시간 학습 가능한 구조가 장기적으로 유리 + +### 2024-2025 최신 기술 동향 반영 +- BERT 계열 모델: F1 0.99 달성 (2024년 연구) +- LLM zero-shot: 학습 데이터 불필요 +- 한국어 특화: KoBERT, KoSentenceBERT 성숙 +- 교훈: 최신 기술 스택 도입이 개발 속도/품질 모두 향상