[#2] 말조심: 필터링 전략

Nyam·2025년 12월 1일

말조심

목록 보기
3/4
post-thumbnail

AI 비용을 최소화하는 욕설 필터링 아키텍처

문제 정의

욕설 필터링 시스템을 구축할 때 가장 큰 고민은 비용과 정확도의 균형입니다.

만약 모든 요청에 AI를 사용한다면:

  • 비용이 너무 높음 (OpenAI API 호출 비용)
  • 응답 시간 증가 (AI 호출은 수백 ms 소요)
  • 확장성 문제 (트래픽 증가 시 비용 폭증)

하지만 AI 없이 사전 매칭만 사용한다면:

  • 회피 패턴 감지 불가 (시8발, 시\u200b발 같은 변형)
  • 맥락 파악 한계 (시발점 같은 False Positive)
  • 새로운 변형 대응 어려움

Fast Path First 전략의 핵심 아이디어

"가능한 한 빠르고 저렴한 방법으로 해결하고, 필요한 경우에만 AI를 사용한다"

이 전략은 다음과 같은 원칙을 따릅니다:

  1. Fast Path 우선: Redis 사전 매칭으로 대부분의 케이스를 처리
  2. 조건부 AI 호출: 명확한 경우에만 Slow Path로 AI 호출
  3. 점진적 확장: Fast Path → Slow Path로 단계적 처리

아키텍처 개요

입력 텍스트
    ↓
[1] 회피 패턴 감지 (suspiciousScore 계산)
    ↓
[2] 텍스트 정규화 (Zero-width 제거 등)
    ↓
[3] 토큰화 & 후보 추출 (Sliding Window)
    ↓
[4] Fast Path: Redis 매칭
    ├─ 전체 단어 매칭
    ├─ 부분 매칭
    └─ 심각도 계산
    ↓
    ├─ 즉시 차단 (score≥0.7) ✅
    └─ AI 호출 필요?
         ↓
    [5] Slow Path: AI 호출 (조건부)
         ↓
    [6] 최종 판정 (ALLOW/WARN/BLOCK)

Fast Path: Redis 사전 매칭

1. 토큰화 및 후보 추출

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

예시:

  • 입력: "안녕하세요 시발"
  • 토큰: ["안녕하세요", "시발"]
  • 후보: ["안", "안녕", "안녕하", ..., "시", "시발", ...]

2. 전체/부분 매칭 구분

False Positive를 방지하기 위해 전체 매칭과 부분 매칭을 구분합니다.
부분매칭의 가중치를 적게 주어야 시발점같은 단어 판별 가능

const isPartialMatch = !tokenSet.has(candidate);

matchedWords.push({
  word: wordInfo.word,
  normalizedWord: candidate,
  severity: wordInfo.severity,
  isPartialMatch,
});

예시:

  • "시발" → 전체 매칭 (정확히 일치)
  • "시발점"에서 "시발" → 부분 매칭 (가중치 50% 감소)

3. 즉시 차단 로직

전체 단어 매칭이 있고 심각도가 높으면 AI 호출 없이 즉시 차단

if (fullMatches.length > 0) {
  const fullScore = this.calculateDictionaryScore(fullMatches);
  if (fullScore >= 0.7) {
    return FilterResponseDto.block(text, fastMatches, fullScore, suspiciousScore);
  }
}

이 로직으로 대부분의 명확한 욕설은 Fast Path에서 처리됩니다.

Slow Path: 조건부 AI 호출

AI는 다음 조건 중 하나라도 만족할 때만 호출됩니다:

const shouldCallAI =
  suspiciousScore > 0.3 || // 1. 회피 패턴 감지
  (fastMatches.length === 0 && text.length <= 20) || // 2. 매칭 없고 짧은 텍스트
  (partialMatches.length > 0 && fullMatches.length === 0); // 3. 부분 매칭만 있음

회피 패턴 감지

5가지 회피 패턴을 감지합니다:

  1. Leetspeak (가중치 0.3): 시8발, cibal
  2. Repetition (가중치 0.2): 시발발발
  3. Jamo Separation (가중치 0.25): ㅅㅣ발
  4. Zero Width (가중치 0.3): 시\u200b발
  5. Space Separation (가중치 0.3): 시 발

각 패턴의 가중치를 합산하여 suspiciousScore를 계산합니다.

AI 후보 추출 및 통합

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);

점수 계산 및 판정

Dictionary Score 계산

심각도별 가중치를 적용하고, 부분 매칭은 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: 명확한 욕설 (Fast Path만 사용)

입력: "시발 개새끼"
  ↓
[1] 회피 패턴: 없음 (suspiciousScore = 0)
  ↓
[2] 정규화: "시발 개새끼"
  ↓
[3] 토큰화: ["시발", "개새끼"]
  ↓
[4] Fast Path 매칭:
    - "시발" → 전체 매칭 (HIGH)
    - "개새끼" → 전체 매칭 (HIGH)
  ↓
[5] dictionaryScore = 0.9 (≥ 0.7)
  ↓
[6] 즉시 차단 (AI 호출 없음) ✅

예시 2: 회피 패턴 (Slow Path 사용)

입력: "시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] 차단 ✅

예시 3: 부분 매칭 (Slow Path 사용)

입력: "시발점"
  ↓
[1] 회피 패턴: 없음
  ↓
[2] 정규화: "시발점"
  ↓
[3] Fast Path 매칭:
    - "시발" → 부분 매칭 (HIGH, 가중치 50% 감소)
  ↓
[4] shouldCallAI = true (부분 매칭만 있음)
  ↓
[5] AI 호출: 맥락 분석
  ↓
[6] AI 판단: "시발점"은 정상 단어 (False Positive)
  ↓
[7] 최종: ALLOW 또는 WARN

설계 원칙

1. 점진적 확장 (Progressive Enhancement)

  • 기본: Fast Path로 대부분 처리
  • 필요 시: Slow Path로 보완

2. 실패 안전 (Fail-Safe)

if (shouldCallAI && this.ragService) {
  try {
    // AI 호출
  } catch (error) {
    // AI 실패 시 Fast Path 결과만 사용
  }
}

AI 실패 시에도 Fast Path 결과로 동작합니다.

3. 데이터 일관성

Write-through 캐싱으로 PostgreSQL과 Redis를 동기화:

async addBadWord(word: BadWord): Promise<void> {
  const badWord = await this.prisma.badWord.create({ data: word });
  await this.cacheService.addBadWord(badWord);
}

성능 최적화 포인트

  1. Redis Set 사용: O(1) 조회
  2. 토큰별 처리: False Positive 감소
  3. 조건부 AI 호출: 필요한 경우에만 실행
  4. 중복 제거: 같은 단어 중복 매칭 방지

전략의 효과

1. 비용 최적화

  • 대부분의 명확한 욕설: Fast Path에서 즉시 차단 (AI 호출 없음)
  • 회피 패턴이나 애매한 경우: Slow Path에서 AI 호출
  • 예상 효과: 대부분의 케이스에서 AI 호출 생략

2. 성능 향상

  • Redis 매칭: O(1) 조회로 빠른 응답
  • AI 호출: 조건부 실행으로 평균 응답 시간 단축

3. 정확도 유지

  • 전체/부분 매칭 구분: False Positive 감소
  • 회피 패턴 감지: 우회 시도 대응
  • AI 활용: 애매한 케이스 처리

마무리

Fast Path First 전략은 다음과 같은 이점을 제공합니다

  • 비용 절감: 대부분의 케이스를 Fast Path로 처리
  • 성능 향상: Redis 매칭으로 빠른 응답
  • 정확도 유지: AI로 애매한 케이스 보완
  • 확장성: 점진적 확장 가능

이 전략은 "단순한 것부터 복잡한 것으로"라는 원칙을 따르며, 비용과 성능을 고려한 실용적인 접근입니다.

다음 글

다음내용은 AI, Vector DB에 관한 이야기를 할 것 같 습니다.

profile
Backend Developer

0개의 댓글