욕설 필터링 시스템을 구축할 때 가장 큰 고민은 비용과 정확도의 균형입니다.
만약 모든 요청에 AI를 사용한다면:
하지만 AI 없이 사전 매칭만 사용한다면:
시8발, 시\u200b발 같은 변형)시발점 같은 False Positive)"가능한 한 빠르고 저렴한 방법으로 해결하고, 필요한 경우에만 AI를 사용한다"
이 전략은 다음과 같은 원칙을 따릅니다:
입력 텍스트
↓
[1] 회피 패턴 감지 (suspiciousScore 계산)
↓
[2] 텍스트 정규화 (Zero-width 제거 등)
↓
[3] 토큰화 & 후보 추출 (Sliding Window)
↓
[4] Fast Path: Redis 매칭
├─ 전체 단어 매칭
├─ 부분 매칭
└─ 심각도 계산
↓
├─ 즉시 차단 (score≥0.7) ✅
└─ AI 호출 필요?
↓
[5] Slow Path: AI 호출 (조건부)
↓
[6] 최종 판정 (ALLOW/WARN/BLOCK)
Sliding Window 알고리즘을 사용하여 모든 가능한 부분 문자열을 생성합니다.
extractCandidates(text: string): string[] {
const candidates = new Set<string>();
const tokens = this.tokenize(text);
for (const token of tokens) {
for (let start = 0; start < token.length; start++) {
for (let end = start + 1; end <= token.length; end++) {
candidates.add(token.substring(start, end));
}
}
}
return Array.from(candidates);
}
예시:
"안녕하세요 시발"["안녕하세요", "시발"]["안", "안녕", "안녕하", ..., "시", "시발", ...]False Positive를 방지하기 위해 전체 매칭과 부분 매칭을 구분합니다.
부분매칭의 가중치를 적게 주어야 시발점같은 단어 판별 가능
const isPartialMatch = !tokenSet.has(candidate);
matchedWords.push({
word: wordInfo.word,
normalizedWord: candidate,
severity: wordInfo.severity,
isPartialMatch,
});
예시:
"시발" → 전체 매칭 (정확히 일치)"시발점"에서 "시발" → 부분 매칭 (가중치 50% 감소)전체 단어 매칭이 있고 심각도가 높으면 AI 호출 없이 즉시 차단
if (fullMatches.length > 0) {
const fullScore = this.calculateDictionaryScore(fullMatches);
if (fullScore >= 0.7) {
return FilterResponseDto.block(text, fastMatches, fullScore, suspiciousScore);
}
}
이 로직으로 대부분의 명확한 욕설은 Fast Path에서 처리됩니다.
AI는 다음 조건 중 하나라도 만족할 때만 호출됩니다:
const shouldCallAI =
suspiciousScore > 0.3 || // 1. 회피 패턴 감지
(fastMatches.length === 0 && text.length <= 20) || // 2. 매칭 없고 짧은 텍스트
(partialMatches.length > 0 && fullMatches.length === 0); // 3. 부분 매칭만 있음
5가지 회피 패턴을 감지합니다:
시8발, cibal시발발발ㅅㅣ발시\u200b발시 발각 패턴의 가중치를 합산하여 suspiciousScore를 계산합니다.
AI가 추출한 후보도 Redis에서 매칭 확인
const aiCandidates = await this.ragService.extractCandidates(text);
const normalizedAiCandidates = this.normalizationService.normalizeBatch(aiCandidates);
const aiMatches = await this.checkDictionary(normalizedAiCandidates, normalizedTokens, clientId);
allMatches = this.mergeMatches(fastMatches, aiMatches);
심각도별 가중치를 적용하고, 부분 매칭은 50% 감소
const severityWeights: Record<Severity, number> = {
LOW: 0.2,
MEDIUM: 0.5,
HIGH: 0.8,
CRITICAL: 1.0,
};
for (const word of matchedWords) {
let weight = severityWeights[word.severity] || 0;
if (word.isPartialMatch) {
weight *= 0.5; // 부분 매칭은 가중치 50% 감소
}
totalWeight += weight;
}
const avgWeight = totalWeight / matchedWords.length;
const countBonus = Math.min(fullMatchCount * 0.1, 0.3);
return Math.min(1.0, avgWeight + countBonus);
private determineStatus(dictionaryScore: number, suspiciousScore: number): FilterStatus {
// 1. 사전 점수가 높으면 차단
if (dictionaryScore >= 0.7) {
return "block";
}
// 2. 사전 점수 + 의심도 점수가 높으면 차단
const combinedScore = dictionaryScore * 0.7 + suspiciousScore * 0.3;
if (combinedScore >= 0.6) {
return "block";
}
// 3. 사전 점수나 의심도가 있으면 경고
if (dictionaryScore > 0 || suspiciousScore > 0.3) {
return "warning";
}
// 4. 그 외는 허용
return "allow";
}
입력: "시발 개새끼"
↓
[1] 회피 패턴: 없음 (suspiciousScore = 0)
↓
[2] 정규화: "시발 개새끼"
↓
[3] 토큰화: ["시발", "개새끼"]
↓
[4] Fast Path 매칭:
- "시발" → 전체 매칭 (HIGH)
- "개새끼" → 전체 매칭 (HIGH)
↓
[5] dictionaryScore = 0.9 (≥ 0.7)
↓
[6] 즉시 차단 (AI 호출 없음) ✅
입력: "시8발"
↓
[1] 회피 패턴: Leetspeak 감지 (suspiciousScore = 0.3)
↓
[2] 정규화: "시8발"
↓
[3] Fast Path 매칭: 없음
↓
[4] shouldCallAI = true (suspiciousScore > 0.3)
↓
[5] AI 호출: "시발" 추출
↓
[6] AI 결과를 Redis에서 매칭: "시발" (HIGH)
↓
[7] dictionaryScore = 0.8 + suspiciousScore = 0.3
↓
[8] 차단 ✅
입력: "시발점"
↓
[1] 회피 패턴: 없음
↓
[2] 정규화: "시발점"
↓
[3] Fast Path 매칭:
- "시발" → 부분 매칭 (HIGH, 가중치 50% 감소)
↓
[4] shouldCallAI = true (부분 매칭만 있음)
↓
[5] AI 호출: 맥락 분석
↓
[6] AI 판단: "시발점"은 정상 단어 (False Positive)
↓
[7] 최종: ALLOW 또는 WARN
if (shouldCallAI && this.ragService) {
try {
// AI 호출
} catch (error) {
// AI 실패 시 Fast Path 결과만 사용
}
}
AI 실패 시에도 Fast Path 결과로 동작합니다.
Write-through 캐싱으로 PostgreSQL과 Redis를 동기화:
async addBadWord(word: BadWord): Promise<void> {
const badWord = await this.prisma.badWord.create({ data: word });
await this.cacheService.addBadWord(badWord);
}
Fast Path First 전략은 다음과 같은 이점을 제공합니다
이 전략은 "단순한 것부터 복잡한 것으로"라는 원칙을 따르며, 비용과 성능을 고려한 실용적인 접근입니다.
다음내용은 AI, Vector DB에 관한 이야기를 할 것 같 습니다.