수달이네 기술 블로그

6. Whisper - ASR모델의 구조 본문

프로젝트

6. Whisper - ASR모델의 구조

슬픈 수달이 2026. 4. 17. 20:26

기초 구조

오디오 청크
    ↓
EnergyVAD (1차 필터)
    ↓ 발화 완성
float32 배열 → Whisper
    ├── 내장 VAD (2차 필터, 환각 방지)
    ├── Beam Search (beam_size=5, 최적 텍스트 탐색)
    └── avg_logprob → exp() → confidence [0,1]
    ↓
TranscriptionResult { text, confidence, language }
    ↓
on_transcription 콜백
  • whisper모델은 위와 같이 구성된다.

Log Probability(avg_logprob)

확률

  • Whisper는 음성을 텍스트로 바꿀 때 내부적으로 음성이 단어에 매칭될 확률을 계산한다.
  • 위의 단어를 예측할 때 토큰 하나씩 예측한다

결합 확률(Joint Probability)

Whisper는 음성을 듣고 토큰(단어/음절)을 하나씩 순서대로 예측한다

  • “안” → “녕” → “하” → “세” → “요”
  • 각 토큰을 예측할 때마다 “이 토큰이 맞을까?”를 계산함.

이 개념을 바탕으로 결합 확률을 계산하는데

$$ P("안녕하세요" 전체가 맞을 확률) = P("안") × P("녕"|"안") × P("하"|"안녕") × P("세"|"안녕하") × P("요"|"안녕하세") $$

  • 위 수식에서 $P("녕"|"안")$일 경우, “안”이후, “녕”이 올 조건부 확률을 구한다.

이렇게 나올 확률을 직관적으로 살피면

"안" 맞을 확률 0.95 "녕" 맞을 확률 0.92 "하" 맞을 확률 0.90 "세" 맞을 확률 0.88 "요" 맞을 확률 0.91

  • 위와 같이 하나하나 보면 높은 확률으로 보이지만.

전체 문장이 맞을 확률 = 0.95 × 0.92 × 0.90 × 0.88 × 0.91 = 0.620

  • 결과 값은 꽤나 낮게 나타남을 확인할 수 있다.

만약, 세그멘테이션이 길어지거나, 확률이 높지 않을 경우 0으로 점차 수렴하여 언더플로우가 발생 0.0으로 처리한다.

로그 확률

위와 같은 문제를 해결하기 위해 곱셈 대신 로그 합을 이용한다.

$log(0.92 × 0.88 × ...) = log(0.92) + log(0.88) + ... = -0.083 + -0.128 + ...$

  • 위와 같이 표현된다.(0~1사이값은 음수로 표현하므로)
  • 따라서 아주 작은 수여도 언더플로우가 발생하지 않는다.

위처럼 변환해서 계산 처리하고, exp()함수를 통해 사람이 읽을 수 있도록 보여준다.

confidence = sum(
    math.exp(seg.avg_logprob) * (seg.end - seg.start)
    for seg in segments
) / total_duration

confidence = max(0.0, min(1.0, confidence)) # 0~1값 사이를 벗어나지 않도록 clamp
avg_logprob = -0.1  → exp(-0.1) = 0.905  → 신뢰도 90%
avg_logprob = -0.5  → exp(-0.5) = 0.607  → 신뢰도 61%
avg_logprob = -1.0  → exp(-1.0) = 0.368  → 신뢰도 37%
avg_logprob = -2.0  → exp(-2.0) = 0.135  → 신뢰도 14%

Beam Search vs Greedy

Greedy 디코딩 방식

매 순간 가장 높은 확률의 단어 하나만 선택하는 방식

입력: "안녕..." 
→ 1번째 토큰: "안녕" (확률 0.9) ✓ 선택
→ 2번째 토큰: "하세요" (확률 0.8) ✓ 선택
→ 결과: "안녕하세요"
  • 매순간 바로 다음 단어가 나올 확률이 가장 높은 단어 하나만 선택하는 방식
  • 전체 문장 기준으로는 최손이 아닐 가능성이 높음

Beam Search(beam_size = n)

beam_size만큼 후보경로를 동시에 유지하며 디코딩하는 방식

시작
├── 경로 1: "안녕" (누적 점수: -0.1)
├── 경로 2: "아"   (누적 점수: -0.3)
├── 경로 3: "네"   (누적 점수: -0.5)
├── 경로 4: "저"   (누적 점수: -0.6)
└── 경로 5: "그"   (누적 점수: -0.7)

다음 스텝 (각 경로에서 확장 후 상위 5개만 유지)
├── 경로 1: "안녕하세요" (누적: -0.2)
├── 경로 2: "아 안녕"    (누적: -0.4)  ← 나중에 더 좋아질 수도
...
  • 이후 최종적으로 누적 점수가 가장 높은 경로를 선택한다.
  • beam_size가 크면 정확도는 높아지지만 속도는 줄어든다.
  • 이런 방식으로 음성을 예측해 나간다.

VAD(faster-whisper)

처음에 VAD를 통해 한번 걸러준다.

  • CPU/GPU를 절약하기 위한 방식으로 묵음 구간을 whisper에 보내지 않기 위함.
# 1) ASRCore에서 먼저 거름 (EnergyVAD)
class ASRCore:
    def push(self, chunk):
        is_speech = self.vad.is_speech(chunk)      # ← 1차 VAD
        audio = self.buffer.push(chunk, is_speech)
        if audio is not None:
            t = threading.Thread(target=self._transcribe, ...)

이후 whisper내부 구조에서 한번 더 걸러준다.

  • 이건 whisper에 넘어온 오디오 안에서 잘라내어 환각을 방지하기 위함.
# 2) Whisper 내부에서도 한 번 더 거름
def transcribe(self, pcm_float32):
    segments_gen, info = self.model.transcribe(
        pcm_float32,
        vad_filter=True,  # ← 2차 VAD (faster-whisper 내장)
        vad_parameters=dict(min_silence_duration_ms=500),
    )

이렇게 2중 VAD를 통해 침묵을 명시적으로 걸러낼 수 있다.

언어 감지

segments_gen, info = self.model.transcribe(
    pcm_float32,
    language=self.config.language,  # "ko" 로 고정
    ...
)

# info.language → Whisper가 감지한 언어 코드
return TranscriptionResult(
    text=text,
    confidence=confidence,
    language=info.language or self.config.language,  # 감지 실패 시 설정값 사용
)
  • 설정 상에서 우린 언어를 ko로 고정하지만 whisper자체에서도 언어를 감지할 수 있다.
  • 고정해줄 경우 오감지 문제를 해결할 수 있고, 속도도 빨라진다.