docs: Coldmail 필터 토큰화 문제 분석 및 KoBERT+LLM+Bayes 하이브리드 개선 방안

- 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 <noreply@anthropic.com>
This commit is contained in:
Claude-51124 2025-10-14 13:01:14 +09:00
parent 72fb4a1072
commit 8fa7298150

View File

@ -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 성숙
- 교훈: 최신 기술 스택 도입이 개발 속도/품질 모두 향상