HOW AI WORKS

빔 서치는
무엇을 찾고 있는가

Deep Speech 2의 신경망은 문장을 출력하지 않습니다. 프레임마다 "이 40ms는 어떤 글자일까"라는 확률표를 뱉을 뿐입니다. 그 확률표 더미에서 문장 하나를 뽑아내는 게 디코딩이고, DS2가 쓰는 방법이 빔 서치입니다. 그런데 각 프레임의 1등을 이어 붙이면 틀립니다. 왜 틀리는지, 빔 서치가 대신 무엇을 세고 있는지, 그리고 논문의 Q(y) = log p_ctc(y|x) + α log p_lm(y) + β·단어수가 무슨 뜻인지 직접 돌려 봅니다.

원 논문: Deep Speech 2: End-to-End Speech Recognition in English and Mandarin (Amodei et al., Baidu Research, arXiv:1512.02595 · ICML 2016) — 언어 모델·디코딩은 3.8절, 배포 최적화는 7.3절 · 디코딩 알고리즘 원전: Hannun et al., 2014

차례
  1. 신경망이 실제로 내놓는 것 — 문장이 아니라 확률표
  2. CTC 축약 규칙 — 여러 정렬이 한 문장이 된다
  3. greedy는 왜 틀리는가 — 최선의 "정렬" ≠ 최선의 "문장"
  4. prefix beam search — 알고리즘을 한 프레임씩 실행하기
  5. 빔 크기 — 얼마나 넓게 볼 것인가 (500 / 200)
  6. 언어 모델 붙이기 — Q(y)의 세 항
  7. 배포 — 40ms마다 100만 번 조회를 어떻게 줄였나
  8. 논문의 결과 — 언어 모델은 얼마나 도왔나
  9. 정리 — 한 문장으로
  10. 이해도 퀴즈

§1 신경망이 실제로 내놓는 것 — 문장이 아니라 확률표

DS2의 출력층을 논문은 이렇게 설명합니다.

"At each output time-step t, the RNN makes a prediction over characters, p(ℓt|x), where ℓt is either a character in the alphabet or the blank symbol. In English we have ℓt ∈ {a, b, c, …, z, space, apostrophe, blank}."

즉 출력은 T개 프레임 × 알파벳 크기짜리 확률 행렬입니다. 5초 발화면 T는 백 단위, 알파벳은 영어 29개(중국어는 6천 개 규모). 여기서 문장 하나를 골라내야 합니다.

여기 blank(빈칸, _로 표기)라는 낯선 기호가 하나 끼어 있습니다. 이건 "이 프레임에는 출력할 글자가 없다"는 뜻입니다. 왜 필요할까요? 글자 하나가 여러 프레임에 걸쳐 발음되기 때문입니다. "cat"의 /a/가 4프레임 동안 이어지면 신경망은 a a a a를 내놓을 텐데, 이걸 그대로 읽으면 "aaaa"가 되죠. 그래서 CTC는 축약 규칙을 씁니다.

§2 CTC 축약 규칙 — 여러 정렬이 한 문장이 된다

CTC는 Connectionist Temporal Classification의 약자입니다 — Graves et al., ICML 2006. 논문 제목이 "Labelling Unsegmented Sequence Data"인데, 여기 unsegmented가 핵심입니다. 음성은 어느 프레임이 어느 글자인지 미리 잘라 두지 않은 데이터라 "몇 번째 프레임 = 글자 c"라는 정답표를 만들 수가 없습니다. 그래서 가능한 정렬을 전부 인정하고 그 확률을 합치는 쪽으로 우회한 것이 CTC이고, 아래 축약 규칙과 blank가 그 우회의 장치입니다. 이 글 전체가 결국 그 "합치기"를 디코딩에서 어떻게 해내느냐는 이야기입니다.

프레임마다 기호를 하나씩 고른 것을 정렬(alignment)이라고 합니다. 정렬을 문장으로 바꾸는 규칙은 딱 두 단계, 그리고 순서가 중요합니다.

① 연속으로 같은 기호가 나오면 하나로 병합   c c _ _ a t t  →  c _ a t
② 남은 blank(_)를 전부 제거                  c _ a t        →  c a t

①을 먼저 하는 덕분에 blank가 "글자 경계 표시" 역할을 합니다. 진짜로 같은 글자가 두 번 나와야 하는 "letter"의 tt는 t _ t처럼 사이에 blank를 끼워 표현합니다. blank가 없으면 "tt"와 "t"를 구별할 방법이 없습니다.

데모 1 · 정렬을 직접 만들어 보기
칸을 클릭하면 기호가 _ → c → a → t 순으로 바뀝니다. 축약 결과가 어떻게 되는지 보세요.
원본 정렬–
① 연속 중복 병합 후–
② blank 제거 후 (최종)–
이 정렬 하나의 확률–
같은 문장을 만드는 정렬 개수–
그 정렬들의 확률 합–
여기가 이 문서 전체의 핵심입니다. 문장 y의 확률은 어느 한 정렬의 확률이 아니라, y로 축약되는 모든 정렬의 확률을 더한 값입니다.
p(y | x)  =   Σ         p(정렬 | x)
            정렬 ∈ 축약⁻¹(y)
디코딩이 찾아야 하는 건 이 합이 가장 큰 y입니다.

§3 greedy는 왜 틀리는가 — 최선의 "정렬" ≠ 최선의 "문장"

가장 단순한 디코딩은 프레임마다 1등 글자를 고르고 축약하는 것입니다(greedy 또는 best path 디코딩). 빠르고, 대부분은 잘 됩니다. 그런데 이건 가장 확률 높은 정렬 하나를 찾을 뿐이지 가장 확률 높은 문장을 찾는 게 아닙니다. 둘은 다릅니다.

아래는 그 차이가 드러나도록 만든 6프레임짜리 장난감 확률표입니다. 파란 칸이 각 프레임의 1등입니다.

데모 2 · greedy vs 실제 최선 문장
4⁶ = 4,096개 정렬을 전부 열거해서 문장별 확률 합을 구합니다 (실전에서는 불가능하지만 장난감에서는 가능합니다).
그 프레임의 최고 확률 기호 (greedy가 고르는 것)
greedy 정렬–
greedy 출력–
확률이 가장 높은 문장–

문장별 확률 (전수 열거, 상위 8개)

결과를 보면 greedy는 "ct"를 내놓지만, 실제로 확률이 가장 높은 문장은 "cat"입니다. 두 배 이상 차이가 납니다. 왜 이런 일이 생길까요?

프레임 3·4를 보세요. _(0.45)가 a(0.40)보다 아주 조금 높습니다. 그래서 greedy는 두 프레임 모두 blank를 고르고 a를 통째로 버립니다. 하지만 "cat"은 a가 프레임 3에만, 4에만, 또는 둘 다 나타나는 여러 정렬로 실현될 수 있고, 그 확률들이 합쳐지면 하나뿐인 blank 경로를 압도합니다.

이게 빔 서치가 필요한 첫 번째 이유입니다. 확률 질량이 여러 정렬에 흩어져 있을 때 greedy는 그걸 모으지 못합니다. 정답을 알아보지 못하는 게 아니라 세지 못하는 겁니다.

§4 prefix beam search — 알고리즘을 한 프레임씩 실행하기

그럼 모든 정렬을 다 세면 되지 않을까요? 안 됩니다. T=100, 알파벳 29개면 정렬은 29¹⁰⁰개입니다.

해법은 정렬이 아니라 문장 접두사(prefix)를 후보로 관리하는 것입니다. 같은 접두사로 축약되는 정렬들을 하나로 묶어서 확률을 합쳐 두고, 상위 몇 개만 남기며 진행합니다. 이게 prefix beam search입니다.

왜 확률을 두 개로 나눠 들고 다니나

구현에서 가장 헷갈리는 부분입니다. 각 접두사마다 확률을 둘로 나눠 보관합니다.

기호의미왜 필요한가
p_b(prefix)이 접두사로 축약되면서 blank로 끝나는 정렬들의 확률 합다음 프레임에 직전과 같은 글자가 오면, blank로 끝났는지 아닌지에 따라 결과가 갈리기 때문
p_nb(prefix)이 접두사로 축약되면서 글자로 끝나는 정렬들의 확률 합

예를 들어 접두사가 "ca"이고 다음 프레임에 또 a가 온다면:

같은 접두사, 같은 다음 글자인데 결과가 다릅니다. 그래서 둘을 섞어 두면 안 됩니다.

경우의 수를 다 나열하면 끝

사실 규칙이랄 게 따로 없습니다. 이전 상태 2가지 × 이번 기호 3가지 = 6가지를 전부 적어 두면 그게 알고리즘입니다. 이전 상태는 blank로 끝났는지 글자로 끝났는지 둘 중 하나고, 이번 기호는 blank인지 · 직전과 같은 글자인지 · 다른 글자인지 셋 중 하나니까요. 접두사를 ℓ, 그 마지막 글자를 x, 그와 다른 글자를 y라고 하면:

이전 상태이번 기호축약 결과도착하는 칸규칙
ℓ, blank로 끝남_ℓℓ의 p_bA
ℓ, 글자로 끝남_ℓℓ의 p_bA
ℓ, 글자로 끝남x (같은 글자)ℓ (합쳐짐)ℓ의 p_nbB
ℓ, blank로 끝남x (같은 글자)ℓ+xℓ+x의 p_nbB
ℓ, blank로 끝남y (다른 글자)ℓ+yℓ+y의 p_nbC
ℓ, 글자로 끝남y (다른 글자)ℓ+yℓ+y의 p_nbC

여기서 3·4번 두 줄만 이전 상태에 따라 도착지가 갈립니다. 입력이 ℓ과 x로 똑같은데 결과가 ℓ과 ℓ+x로 달라지죠. 나머지 네 줄(1·2번, 5·6번)은 이전 상태가 무엇이든 같은 칸으로 갑니다 — 그래서 아래 코드가 s["pb"] + s["pnb"]로 더해서 한 번에 보냅니다. 확률을 둘로 나눠 들고 다녀야 하는 이유는 저 두 줄 하나뿐이고, 나머지는 그 대가로 치르는 부기(bookkeeping)입니다.

# prefix beam search — 핵심 갱신 규칙 (선형 확률 기준)
for t in range(T):
    next = defaultdict(lambda: {"pb": 0.0, "pnb": 0.0})
    for prefix, s in beams.items():
        last = prefix[-1] if prefix else None
        for ch, pc in dist[t].items():

            if ch == BLANK:
                # blank: 접두사 그대로, blank로 끝남
                next[prefix]["pb"] += pc * (s["pb"] + s["pnb"])

            elif ch == last:
                # 직전과 같은 글자 — 두 갈래로 갈린다
                next[prefix]["pnb"]      += pc * s["pnb"]   # 병합 → 그대로
                next[prefix + ch]["pnb"] += pc * s["pb"]    # blank 경계 → 확장

            else:
                # 새로운 글자 — 무조건 확장
                next[prefix + ch]["pnb"] += pc * (s["pb"] + s["pnb"])

    # 상위 beam_width 개만 남긴다  ← 여기가 "빔"
    beams = top_k(next, key=lambda s: s["pb"] + s["pnb"], k=beam_width)
prefix beam search 갱신 규칙 순서도 빈 접두사로 시작해 프레임 루프 안에서 빔과 기호를 이중으로 돌며, ch가 blank인지 직전 글자와 같은지에 따라 세 갈래(유지·분기·확장)로 p_b와 p_nb를 갱신하고, 프레임 끝마다 top_k로 빔 폭만큼 남긴 뒤, 마지막에 p_b+p_nb가 가장 큰 접두사를 문장으로 내놓는 흐름도. 프레임 루프 · t = 0 … T−1 ↺ 다음 프레임 빔 루프 · (prefix, s) ∈ beams ↺ 다음 빔 기호 루프 · (ch, pc) ∈ dist[t] ↺ 다음 기호 beams = { "" : pb = 1.0, pnb = 0.0 } 빈 접두사 하나로 시작 next = 빈 표 (pb = 0, pnb = 0) last = prefix[-1] 빈 접두사면 None ch == BLANK ? 예 A · 유지 next[prefix].pb += pc × (pb + pnb) blank는 어느 쪽 뒤에 와도 결과가 같다 → 합쳐서 흡수 아니오 ch == last ? 예 B · 분기 next[prefix].pnb += pc × pnb …a 뒤에 a → 병합, 접두사 그대로 next[prefix+ch].pnb += pc × pb …a_ 뒤에 a → blank가 경계, 확장 아니오 C · 확장 next[prefix+ch].pnb += pc × (pb + pnb) 새 글자는 무조건 확장 beams = top_k(next, pb + pnb, beam_width) ← 여기가 "빔" · 지수를 선형으로 자르는 유일한 지점 argmax(pb + pnb) → 최종 문장
위 코드를 그대로 옮긴 순서도. 지면상 s["pb"]를 pb로 줄여 썼습니다.

순서도로 보면 세 가지가 눈에 들어옵니다.

순서도를 데모 2의 확률표로 한 번 돌려 보기 (빔 폭 3)

코드와 순서도만으로는 손에 잘 안 잡힙니다. 그래서 §3 데모 2의 확률표를 그대로 넣고 6프레임을 끝까지 돌려 보겠습니다. 각 프레임마다 어떤 빔에 어떤 기호가 붙어 어떤 규칙을 타고 어디에 얼마를 더했는지를 한 줄도 빼지 않고 적었습니다. 규칙 칸의 A·B·C는 위 순서도의 세 상자와 같습니다.

A ch == blank → 접두사 그대로, p_b에 적립 B ch == last → 병합은 p_nb에서, 확장은 p_b에서 C 새 글자 → 무조건 확장
t = 0_ 0.05 · c 0.90 · a 0.03 · t 0.02

빔은 빈 접두사 하나뿐입니다. last가 None이라 B 규칙은 아예 걸리지 않습니다. blank는 A로 ""의 p_b에 들어가고, 나머지 세 글자는 C로 각자 새 접두사의 p_nb에 들어갑니다. 이 프레임에서 새로 생긴 접두사는 모두 p_b = 0이라는 점을 봐 두세요.

빔ch규칙축약 결과갱신
""p_b 1.00 · p_nb 0.00_ 0.05A"" 유지next[""].p_b += 0.05 × (1.00 + 0.00) = 0.05
c 0.90C"c" 확장next["c"].p_nb += 0.90 × (1.00 + 0.00) = 0.90
a 0.03C"a" 확장next["a"].p_nb += 0.03 × (1.00 + 0.00) = 0.03
t 0.02C"t" 확장next["t"].p_nb += 0.02 × (1.00 + 0.00) = 0.02
↓ top_k(next, p_b + p_nb, 3)
순위접두사p_bp_nb합
1"c"0.000.900.90
2""0.050.000.05
3"a"0.000.030.03
✂️ 버림 — "t" 0.02
t = 1_ 0.35 · c 0.60 · a 0.03 · t 0.02

첫 B 규칙이 등장합니다. "c" 뒤에 또 c가 올 때 병합 쪽("c"의 p_nb)은 0.54를 받지만, 확장 쪽 "cc"는 0.60 × p_b("c") = 0.60 × 0 = 0입니다. "c"는 아직 blank로 끝난 적이 없으니 cc를 만들 길 자체가 없습니다 — 확률을 둘로 나눠 든 대가를 여기서 처음 회수합니다.

빔ch규칙축약 결과갱신
"c"p_b 0.00 · p_nb 0.90_ 0.35A"c" 유지next["c"].p_b += 0.35 × (0.00 + 0.90) = 0.315
c 0.60B"c" 병합
"cc" 확장
next["c"].p_nb += 0.60 × 0.90 = 0.54
next["cc"].p_nb += 0.60 × 0.00 = 0.00 (← p_b가 0이라 확장 불가)
a 0.03C"ca" 확장next["ca"].p_nb += 0.03 × (0.00 + 0.90) = 0.027
t 0.02C"ct" 확장next["ct"].p_nb += 0.02 × (0.00 + 0.90) = 0.018
""p_b 0.05 · p_nb 0.00_ 0.35A"" 유지next[""].p_b += 0.35 × (0.05 + 0.00) = 0.0175
c 0.60C"c" 확장next["c"].p_nb += 0.60 × (0.05 + 0.00) = 0.03
a 0.03C"a" 확장next["a"].p_nb += 0.03 × (0.05 + 0.00) = 0.0015
t 0.02C"t" 확장next["t"].p_nb += 0.02 × (0.05 + 0.00) = 0.001
"a"p_b 0.00 · p_nb 0.03_ 0.35A"a" 유지next["a"].p_b += 0.35 × (0.00 + 0.03) = 0.0105
c 0.60C"ac" 확장next["ac"].p_nb += 0.60 × (0.00 + 0.03) = 0.018
a 0.03B"a" 병합
"aa" 확장
next["a"].p_nb += 0.03 × 0.03 = 0.0009
next["aa"].p_nb += 0.03 × 0.00 = 0.00 (← p_b가 0이라 확장 불가)
t 0.02C"at" 확장next["at"].p_nb += 0.02 × (0.00 + 0.03) = 0.0006
↓ top_k(next, p_b + p_nb, 3)
순위접두사p_bp_nb합
1"c"0.3150.570.885
2"ca"0.000.0270.027
3"ct"0.000.0180.018
✂️ 버림 — "ac" 0.018 · "" 0.0175 · "a" 0.0129 · "t" 0.001 · "at" 0.0006 · "cc" 0.00 · "aa" 0.00
"c"의 p_nb 0.57은 어디서 왔나 — 한 줄이 아니라 서로 다른 두 빔이 같은 칸에 더한 결과입니다.
  • 빔 "c"에서 B 병합 — 0.60 × 0.90 = 0.54
    c 뒤에 또 c → 합쳐져서 여전히 "c". 정렬로는 c c.
  • 빔 ""에서 C 확장 — 0.60 × (0.05 + 0.00) = 0.03
    빈 접두사(t=0에서 blank였던 경로) 뒤에 c가 처음 붙어 "c". 정렬로는 _ c.
0.54 + 0.03 = 0.57. §4 첫머리의 "같은 접두사로 축약되는 정렬들을 하나로 묶어서 확률을 합쳐 둔다"가 바로 이 덧셈이고, 코드에서는 next가 접두사를 키로 쓰는 딕셔너리라서 저절로 일어납니다.
t = 2_ 0.45 · c 0.05 · a 0.40 · t 0.10

§3에서 greedy가 무너지는 바로 그 프레임입니다. _(0.45)가 a(0.40)보다 높아 greedy는 a를 통째로 버리지만, 빔은 C 규칙 한 줄(0.40 × 0.885)로 "ca"를 0.37695까지 키워 2위로 들고 갑니다. 1위 "c"(0.42675)와 거의 붙었습니다.

빔ch규칙축약 결과갱신
"c"p_b 0.315 · p_nb 0.57_ 0.45A"c" 유지next["c"].p_b += 0.45 × (0.315 + 0.57) = 0.39825
c 0.05B"c" 병합
"cc" 확장
next["c"].p_nb += 0.05 × 0.57 = 0.0285
next["cc"].p_nb += 0.05 × 0.315 = 0.01575
a 0.40C"ca" 확장next["ca"].p_nb += 0.40 × (0.315 + 0.57) = 0.354
t 0.10C"ct" 확장next["ct"].p_nb += 0.10 × (0.315 + 0.57) = 0.0885
"ca"p_b 0.00 · p_nb 0.027_ 0.45A"ca" 유지next["ca"].p_b += 0.45 × (0.00 + 0.027) = 0.01215
c 0.05C"cac" 확장next["cac"].p_nb += 0.05 × (0.00 + 0.027) = 0.00135
a 0.40B"ca" 병합
"caa" 확장
next["ca"].p_nb += 0.40 × 0.027 = 0.0108
next["caa"].p_nb += 0.40 × 0.00 = 0.00 (← p_b가 0이라 확장 불가)
t 0.10C"cat" 확장next["cat"].p_nb += 0.10 × (0.00 + 0.027) = 0.0027
"ct"p_b 0.00 · p_nb 0.018_ 0.45A"ct" 유지next["ct"].p_b += 0.45 × (0.00 + 0.018) = 0.0081
c 0.05C"ctc" 확장next["ctc"].p_nb += 0.05 × (0.00 + 0.018) = 0.0009
a 0.40C"cta" 확장next["cta"].p_nb += 0.40 × (0.00 + 0.018) = 0.0072
t 0.10B"ct" 병합
"ctt" 확장
next["ct"].p_nb += 0.10 × 0.018 = 0.0018
next["ctt"].p_nb += 0.10 × 0.00 = 0.00 (← p_b가 0이라 확장 불가)
↓ top_k(next, p_b + p_nb, 3)
순위접두사p_bp_nb합
1"c"0.398250.02850.42675
2"ca"0.012150.36480.37695
3"ct"0.00810.09030.0984
✂️ 버림 — "cc" 0.01575 · "cta" 0.0072 · "cat" 0.0027 · "cac" 0.00135 · "ctc" 0.0009 · "caa" 0.00 · "ctt" 0.00
t = 3_ 0.45 · c 0.05 · a 0.40 · t 0.10

순위가 뒤집히는 프레임. "ca"가 세 곳에서 동시에 확률을 받는 것을 보세요. A로 p_b 0.169628(a가 t=2에만 있던 정렬들), C로 0.1707(a가 t=3에만), B의 병합으로 0.14592(t=2·t=3 둘 다). 합 0.486248로 "c"(0.193463)를 앞지릅니다. §3의 “흩어진 확률 질량을 모은다”가 정확히 이 세 줄입니다.

빔ch규칙축약 결과갱신
"c"p_b 0.39825 · p_nb 0.0285_ 0.45A"c" 유지next["c"].p_b += 0.45 × (0.39825 + 0.0285) = 0.192037
c 0.05B"c" 병합
"cc" 확장
next["c"].p_nb += 0.05 × 0.0285 = 0.001425
next["cc"].p_nb += 0.05 × 0.39825 = 0.019912
a 0.40C"ca" 확장next["ca"].p_nb += 0.40 × (0.39825 + 0.0285) = 0.1707
t 0.10C"ct" 확장next["ct"].p_nb += 0.10 × (0.39825 + 0.0285) = 0.042675
"ca"p_b 0.01215 · p_nb 0.3648_ 0.45A"ca" 유지next["ca"].p_b += 0.45 × (0.01215 + 0.3648) = 0.169628
c 0.05C"cac" 확장next["cac"].p_nb += 0.05 × (0.01215 + 0.3648) = 0.018848
a 0.40B"ca" 병합
"caa" 확장
next["ca"].p_nb += 0.40 × 0.3648 = 0.14592
next["caa"].p_nb += 0.40 × 0.01215 = 0.00486
t 0.10C"cat" 확장next["cat"].p_nb += 0.10 × (0.01215 + 0.3648) = 0.037695
"ct"p_b 0.0081 · p_nb 0.0903_ 0.45A"ct" 유지next["ct"].p_b += 0.45 × (0.0081 + 0.0903) = 0.04428
c 0.05C"ctc" 확장next["ctc"].p_nb += 0.05 × (0.0081 + 0.0903) = 0.00492
a 0.40C"cta" 확장next["cta"].p_nb += 0.40 × (0.0081 + 0.0903) = 0.03936
t 0.10B"ct" 병합
"ctt" 확장
next["ct"].p_nb += 0.10 × 0.0903 = 0.00903
next["ctt"].p_nb += 0.10 × 0.0081 = 0.00081
↓ top_k(next, p_b + p_nb, 3)
순위접두사p_bp_nb합
1"ca"0.1696280.316620.486248
2"c"0.1920370.0014250.193463
3"ct"0.044280.0517050.095985
✂️ 버림 — "cta" 0.03936 · "cat" 0.037695 · "cc" 0.019912 · "cac" 0.018848 · "ctc" 0.00492 · "caa" 0.00486 · "ctt" 0.00081
t = 4_ 0.15 · c 0.02 · a 0.03 · t 0.80

t가 0.80으로 솟습니다. C 규칙 한 줄(0.80 × 0.486248)로 "cat"이 태어나자마자 1위가 됩니다. 반대로 네 프레임 내내 1위 아니면 2위였던 "c"는 0.029048로 밀려 여기서 잘립니다 — 빔은 선두를 지켜 주지 않습니다.

빔ch규칙축약 결과갱신
"ca"p_b 0.169628 · p_nb 0.31662_ 0.15A"ca" 유지next["ca"].p_b += 0.15 × (0.169628 + 0.31662) = 0.072937
c 0.02C"cac" 확장next["cac"].p_nb += 0.02 × (0.169628 + 0.31662) = 0.009725
a 0.03B"ca" 병합
"caa" 확장
next["ca"].p_nb += 0.03 × 0.31662 = 0.009499
next["caa"].p_nb += 0.03 × 0.169628 = 0.005089
t 0.80C"cat" 확장next["cat"].p_nb += 0.80 × (0.169628 + 0.31662) = 0.388998
"c"p_b 0.192037 · p_nb 0.001425_ 0.15A"c" 유지next["c"].p_b += 0.15 × (0.192037 + 0.001425) = 0.029019
c 0.02B"c" 병합
"cc" 확장
next["c"].p_nb += 0.02 × 0.001425 = 0.000029
next["cc"].p_nb += 0.02 × 0.192037 = 0.003841
a 0.03C"ca" 확장next["ca"].p_nb += 0.03 × (0.192037 + 0.001425) = 0.005804
t 0.80C"ct" 확장next["ct"].p_nb += 0.80 × (0.192037 + 0.001425) = 0.15477
"ct"p_b 0.04428 · p_nb 0.051705_ 0.15A"ct" 유지next["ct"].p_b += 0.15 × (0.04428 + 0.051705) = 0.014398
c 0.02C"ctc" 확장next["ctc"].p_nb += 0.02 × (0.04428 + 0.051705) = 0.00192
a 0.03C"cta" 확장next["cta"].p_nb += 0.03 × (0.04428 + 0.051705) = 0.00288
t 0.80B"ct" 병합
"ctt" 확장
next["ct"].p_nb += 0.80 × 0.051705 = 0.041364
next["ctt"].p_nb += 0.80 × 0.04428 = 0.035424
↓ top_k(next, p_b + p_nb, 3)
순위접두사p_bp_nb합
1"cat"0.000.3889980.388998
2"ct"0.0143980.1961340.210532
3"ca"0.0729370.0153020.08824
✂️ 버림 — "ctt" 0.035424 · "c" 0.029048 · "cac" 0.009725 · "caa" 0.005089 · "cc" 0.003841 · "cta" 0.00288 · "ctc" 0.00192
t = 5_ 0.70 · c 0.02 · a 0.03 · t 0.25

마지막 프레임은 blank가 0.70이라 A 규칙이 주도합니다. "cat"은 p_nb 0.388998을 A로 p_b 0.272299에 옮겨 담고, 살아남은 "ca"도 C로 0.02206을 "cat"에 보탭니다. "catt"가 또 0인 이유는 t=1의 "cc"와 같습니다.

빔ch규칙축약 결과갱신
"cat"p_b 0.00 · p_nb 0.388998_ 0.70A"cat" 유지next["cat"].p_b += 0.70 × (0.00 + 0.388998) = 0.272299
c 0.02C"catc" 확장next["catc"].p_nb += 0.02 × (0.00 + 0.388998) = 0.00778
a 0.03C"cata" 확장next["cata"].p_nb += 0.03 × (0.00 + 0.388998) = 0.01167
t 0.25B"cat" 병합
"catt" 확장
next["cat"].p_nb += 0.25 × 0.388998 = 0.09725
next["catt"].p_nb += 0.25 × 0.00 = 0.00 (← p_b가 0이라 확장 불가)
"ct"p_b 0.014398 · p_nb 0.196134_ 0.70A"ct" 유지next["ct"].p_b += 0.70 × (0.014398 + 0.196134) = 0.147372
c 0.02C"ctc" 확장next["ctc"].p_nb += 0.02 × (0.014398 + 0.196134) = 0.004211
a 0.03C"cta" 확장next["cta"].p_nb += 0.03 × (0.014398 + 0.196134) = 0.006316
t 0.25B"ct" 병합
"ctt" 확장
next["ct"].p_nb += 0.25 × 0.196134 = 0.049034
next["ctt"].p_nb += 0.25 × 0.014398 = 0.003599
"ca"p_b 0.072937 · p_nb 0.015302_ 0.70A"ca" 유지next["ca"].p_b += 0.70 × (0.072937 + 0.015302) = 0.061768
c 0.02C"cac" 확장next["cac"].p_nb += 0.02 × (0.072937 + 0.015302) = 0.001765
a 0.03B"ca" 병합
"caa" 확장
next["ca"].p_nb += 0.03 × 0.015302 = 0.000459
next["caa"].p_nb += 0.03 × 0.072937 = 0.002188
t 0.25C"cat" 확장next["cat"].p_nb += 0.25 × (0.072937 + 0.015302) = 0.02206
↓ top_k(next, p_b + p_nb, 3)
순위접두사p_bp_nb합
1"cat"0.2722990.1193090.391608
2"ct"0.1473720.0490340.196406
3"ca"0.0617680.0004590.062227
✂️ 버림 — "cata" 0.01167 · "catc" 0.00778 · "cta" 0.006316 · "ctc" 0.004211 · "ctt" 0.003599 · "caa" 0.002188 · "cac" 0.001765 · "catt" 0.00

여섯 프레임을 한 줄씩으로 줄이면

t1위합그 프레임에서 일어난 일
0"c"0.90빈 접두사에서 C 규칙 셋이 갈라져 나옴. "t"는 여기서 이미 탈락
1"c"0.885"cc"가 0으로 막힘 — p_b/p_nb 분리가 처음으로 일함
2"c"0.42675greedy가 버린 a를 "ca"(0.37695)로 살려 둠
3"ca"0.486248세 갈래로 흩어져 있던 확률이 합쳐지며 역전
4"cat"0.388998t=0.80으로 "cat" 탄생. 대신 "c"가 탈락
5"cat"0.391608blank 0.70을 A로 흡수하며 확정

최종 1위는 "cat", 추정 확률 0.391608입니다. 데모 2의 전수 열거가 알려 준 참값은 0.424863(정렬 84개의 합)이니 빔 폭 3으로 그 92%를 주워 담은 셈입니다. 남은 8%는 잘려 나간 후보들이 들고 있던 몫이라 영영 못 찾지만, 순위를 맞히는 데는 지장이 없었습니다. 같은 표에서 greedy는 "ct"를 내놓았다는 것과 비교해 보세요.

표를 볼 때 걸리는 것 두 가지. (1) t=1에서 "ct"와 "ac"는 0.018로 동점인데 "ct"가 살아남습니다 — 정렬이 안정 정렬이라 먼저 만들어진 쪽이 앞에 서기 때문이고, 알고리즘이 아니라 구현 세부입니다. (2) 새로 생긴 접두사는 그 프레임에 항상 p_b = 0입니다(B·C는 p_nb에만 적립). "cat"도 t=4에는 p_b = 0이었다가 t=5에 A를 타고서야 0.272299를 받습니다.

아래에서 이 알고리즘을 실제로 한 프레임씩 돌려 볼 수 있습니다. 위 확률표를 그대로 씁니다.

데모 3 · prefix beam search 스텝 실행기
"다음 프레임 ▶"을 누르며 빔 목록이 어떻게 변하는지 보세요. 빔 폭을 1로 두면 greedy와 비슷하게 망가집니다.
현재 프레임시작 전
살아 있는 빔1
현재 1위""
이번 프레임에 새로 생긴 접두사 연한 회색 행 = 빔 폭에 밀려 버려진 후보

§5 빔 크기 — 얼마나 넓게 볼 것인가

빔 폭은 "매 프레임 상위 몇 개를 살려 둘 것인가"입니다. 넓을수록 정확하고 느립니다. 장난감 예제에서 폭을 바꿔 가며 최종 결과를 보면 이렇습니다. 왜 하필 "빔"인가 ↓

데모 4 · 빔 폭에 따른 최종 결과
전수 열거로 구한 정답은 "cat" (p = 0.42486)입니다.

폭 1은 "ct"로 망하고, 폭 2에서 이미 "cat"을 찾습니다. 그다음부터는 확률 추정치만 조금씩 정밀해질 뿐 답이 바뀌지 않죠. 이 포화 현상이 실전에서도 그대로 나타납니다. 논문은 6.2절에서 Mandarin에 대해 이렇게 씁니다.

"the performance of the beam search during decoding levels off at a smaller beam size. This allows us to use a beam size of 200 with a negligible degradation in CER."

그래서 최종 설정은 영어 500, 중국어 200입니다. 영어가 더 큰 이유는 알파벳이 작아 한 글자에 담긴 정보가 적고(4.9 bits/char vs 12.6), 초당 글자 수가 많아(14.1자 vs 3.3자) 애매한 갈림길이 훨씬 자주 생기기 때문입니다.

§6 언어 모델 붙이기 — Q(y)의 세 항

여기까지는 음향 정보만 썼습니다. 그런데 신경망은 "recognize speech"와 "wreck a nice beach"를 소리만으로는 구분하기 어렵습니다. 사람도 그렇죠. 구분하는 건 어느 쪽이 있을 법한 말인가라는 지식입니다.

언어 모델이란 무엇인가 — 소리를 안 보고 문장에만 점수를 매기는 표

언어 모델(LM)은 음성을 전혀 모릅니다. 글자·단어의 나열 하나를 받아 "이게 실제 텍스트에 나올 법한가"만 확률로 답합니다. 지금까지 다룬 음향 모델(CTC 신경망)과는 따로 학습해서, 디코딩할 때만 합칩니다. 그래서 §1의 확률표에는 LM이 관여한 적이 없습니다.

DS2가 쓰는 LM은 신경망이 아니라 n-gram입니다. 거대한 텍스트 뭉치에서 단어 나열의 빈도를 세어 만든 조회 표이고, 가정은 하나뿐입니다 — 다음 단어는 바로 앞 n−1개 단어에만 의존한다. 영어 모델이 5-gram이니 앞 4단어만 봅니다.

p_lm("wreck a nice beach")
  = p(wreck | <s>) · p(a | <s> wreck) · p(nice | <s> wreck a) · p(beach | <s> wreck a nice)

텍스트에서 센 결과 (예시 수치):
  "recognize speech"  흔함  →  p(speech | … recognize)  = 0.021
  "nice beach"        흔함  →  p(beach  | … a nice)     = 0.008
  "wreck a"           드묾  →  p(a      | … wreck)      = 0.0004   ← 여기서 벌어진다

소리가 거의 같아도 단어 나열을 세어 보면 차이가 납니다. 빔 서치는 빔을 확장할 때마다 이 표를 두드려서 그 차이를 점수에 반영합니다. 아래는 표 뒤에 숨은 용어들입니다.

용어뜻
n-gram 연속한 단어 n개. "8.5억 개"는 "앞 4단어 → 다음 단어" 조합 8.5억 개의 확률을 표로 들고 있다는 뜻입니다
Kneser-Ney 학습 텍스트에 한 번도 없던 조합에 확률 0을 주면 문장 전체가 0이 됩니다. 그래서 센 확률을 조금씩 깎아 미등장 조합에 나눠 주는데(=스무딩), 깎은 몫을 "그 단어가 몇 가지 서로 다른 문맥 뒤에 오는가"에 비례해 분배하는 게 Kneser-Ney입니다. Francisco는 빈도는 높아도 거의 San 뒤에만 오므로 새 문맥에서는 낮게 잡힙니다
단어 LM vs 문자 LM 영어는 단어 단위, 중국어는 문자 단위입니다(아래 표). 중국어는 띄어쓰기가 없어 단어 경계를 먼저 정해야 하는데, 문자 단위로 가면 그 문제를 통째로 피합니다
어휘 400,000개 표 밖의 단어는 LM이 점수를 줄 수 없습니다. 고유명사·신조어에서 α · log p_lm이 오히려 정답을 깎아내리는 원인이 됩니다
왜 신경망 LM이 아닌가. 성능이 아니라 속도 때문입니다. 빔 서치는 매 프레임 · 모든 빔 · 모든 후보 글자마다 LM을 한 번씩 두드립니다(§7에서 40ms에 100만 번). n-gram은 그게 메모리 읽기 한 번이지만, 신경망 LM은 순전파 한 번입니다. 디코딩 시점에 외부 LM 점수를 더하는 이 구조 자체는 지금도 shallow fusion이라는 이름으로 쓰이고, 달라진 건 표 자리에 신경망이 들어간 것뿐입니다.

DS2는 외부 n-gram 언어 모델을 붙이고, 빔 서치가 최대화하는 목적함수를 이렇게 바꿉니다 (식 12).

Q(y) = log p_ctc(y | x)  +  α · log p_lm(y)  +  β · word_count(y)
       └─ 음향: 소리와      └─ 언어: 말이       └─ 단어 삽입 보너스
          맞는가?              되는가?
항역할없으면?
log p_ctc지금까지 만든 그 확률 합소리를 무시하고 흔한 문장만 뱉음
α · log p_lm말이 되는 문장에 가산점. α는 언어 모델의 발언권철자는 맞는데 뜻이 안 통하는 문장이 이김
β · word_count단어 수에 비례한 보너스짧은 문장이 유리해져 단어를 빠뜨림 ↓

β가 왜 필요한지가 특히 헷갈리는 지점입니다. log p_lm은 단어를 하나 더할 때마다 음수 값이 더해지므로, 언어 모델은 본질적으로 짧은 문장을 선호합니다. 이걸 그냥 두면 디코더가 단어를 슬금슬금 빠뜨립니다. β는 그 편향을 상쇄하는 평형추입니다 — 다만 너무 키우면 반대로 없는 단어를 만들어 냅니다. 직접 확인해 보세요.

데모 5 · α와 β로 순위 뒤집기
같은 음향 확률에서 후보 5개의 Q(y) 순위가 어떻게 바뀌는지 봅니다. 막대는 Q(y)를 최고점 기준으로 정렬한 것입니다.
현재 1위–
정답과 일치?–
이 표의 log p_ctc·log p_lm 값은 합성 예시입니다. 논문에 나오는 수치가 아니라, 세 항이 어떻게 겨루는지 보여주려고 만든 값입니다. "recognize speech" / "wreck a nice beach"는 음성 인식 교재의 고전적인 예문입니다. (α·β는 논문에서도 "tuned on a development set"이라고만 하고 값을 밝히지 않습니다.)

언어 모델의 실제 규모 (3.8절)

영어중국어
종류Kneser-Ney 5-gram (단어)Kneser-Ney 5-gram (문자)
어휘최빈 400,000 단어—
학습 텍스트Common Crawl 2.5억 줄내부 코퍼스 80억 줄
n-gram 수약 8.5억약 20억
빔 크기500200

§7 배포 — 40ms마다 100만 번 조회를 어떻게 줄였나

여기서 실전 문제가 터집니다. 빔 서치는 매 프레임, 모든 빔 × 모든 후보 글자에 대해 언어 모델을 조회해야 합니다. 그리고 n-gram 조회는 대부분 캐시에 없는 메모리 읽기입니다.

"The direct implementation of beam search means that each time-step dispatches one lookup per character for each beam. In Mandarin, this results in over 1M lookups per 40ms stride of speech data, which is too slow for deployment."

해법은 놀랍도록 단순합니다.

"Rather than considering all characters as viable additions to the beam, we only consider the fewest number of characters whose cumulative probability is at least p. In practice, we have found that p = 0.99 works well. Additionally, we limit ourselves to no more than 40 characters. This speeds up the Mandarin language model lookup time by a factor of 150x, and has a negligible effect on the CER (0.1-0.3% relative)."

즉 확률 낮은 글자는 아예 후보에서 뺍니다. ASR 출력 분포는 대개 매우 뾰족해서, 누적 확률 99%를 채우는 데 몇 글자면 충분합니다. 나머지 수천 글자를 조회하는 건 순수한 낭비죠.

데모 6 · 가지치기 계산기
막대는 확률 순으로 정렬된 후보 글자들입니다. 빨간 선까지가 누적 p를 채우는 지점입니다.
λ가 작을수록 신경망이 확신에 찬 프레임 (후보 몇 개면 99%가 채워짐) λ가 클수록 애매한 프레임 (후보가 많이 필요 → 상한 40자가 실제로 작동)
p를 채우는 데 필요한 글자–
실제 후보 수 (상한 적용)–
가지치기 전 조회 (프레임당)–
가지치기 후 조회–
가속–
논문의 "150배"는 그냥 나눗셈입니다. 중국어 어휘가 6,000자 규모이고 상한이 40자니까 6000 ÷ 40 = 150. 그리고 빔 200 × 6,000자 = 120만 회 — 논문의 "over 1M lookups per 40ms stride"와 정확히 맞아떨어집니다. 기본값(λ=9)에서 위 계산기가 이 숫자들을 그대로 재현합니다.

λ 슬라이더를 왼쪽으로 밀어 보세요. 신경망이 확신에 찬 프레임에서는 후보 대여섯 개면 누적 99%가 채워지고, 가속은 150배를 훌쩍 넘습니다. 즉 150배는 상한 40자가 걸리는 최악의 경우이고, 평균적으로는 더 절약됩니다. 반대로 λ를 키우면 상한이 없을 때 후보가 수십~수백 개로 불어나는데, 논문이 p=0.99 외에 "최대 40자"라는 하드 리밋을 하나 더 둔 이유가 여기 있습니다 — 애매한 프레임에서 비용이 터지는 걸 막는 안전핀입니다.

§8 논문의 결과 — 언어 모델은 얼마나 도왔나

논문 Table 6은 같은 모델을 언어 모델 없이 / 있게 디코딩한 결과입니다 (영어는 WER, 중국어는 CER, 낮을수록 좋음).

데모 7 · Table 6 — 언어 모델의 효과
막대가 짧을수록 좋습니다. 순환층이 많아질수록 언어 모델의 상대적 기여가 줄어듭니다.
언어구조LM 없음LM 있음상대 개선
영어 (WER)5층, 순환 127.7914.3948.2%
영어 (WER)9층, 순환 714.939.5236.2%
중국어 (CER)5층, 순환 19.807.1327.2%
중국어 (CER)9층, 순환 77.555.8123.1%

논문의 해석은 이렇습니다 — 순환층이 깊어질수록 신경망이 내부에 더 강한 암묵적 언어 모델을 학습하므로, 외부 언어 모델이 보탤 게 줄어듭니다. 실제로 3.8절은 DS2가 외부 제약 없이도 철자를 꽤 잘 쓰고, 동음이의어를 문맥으로 구분해 내는 사례가 많다고 언급합니다.

그리고 영어가 중국어보다 언어 모델 이득이 큽니다. 영어 문자 모델은 철자를 지어내기 쉬운 반면("beach" vs "beech"), 중국어는 문자 하나하나가 이미 의미 단위라 음향만으로도 제약이 강하기 때문입니다.

§9 정리 — 한 문장으로

DS2의 디코딩은 "프레임별 1등 이어 붙이기"가 아니라, 같은 문장으로 축약되는 모든 정렬의 확률을 합산하면서 접두사 후보 상위 N개를 끌고 가는 탐색이다.
거기에 외부 n-gram 언어 모델(α)과 단어 삽입 보너스(β)를 더한 Q(y) = log p_ctc + α log p_lm + β·단어수를 최대화한다. 빔은 영어 500·중국어 200이고, 배포에서는 후보 글자를 누적 확률 0.99까지·최대 40자로 잘라 언어 모델 조회를 150배 줄였다 (CER 영향 0.1~0.3% 상대).

헷갈리기 쉬운 것들

오해실제
빔 서치가 "가장 확률 높은 경로"를 찾는다가장 확률 높은 문장을 찾는다. 경로(정렬)는 합쳐 없앤다
blank는 무음(silence)이다"출력할 글자 없음" 표시. 무음과는 별개이며, 같은 글자의 경계 표시 역할이 핵심
언어 모델이 CTC 확률을 대체한다가중합이다. α로 발언권을 조절할 뿐 음향 항은 그대로 남는다
β는 성능 튜닝용 잡다한 항이다log p_lm의 길이 편향을 상쇄하는 구조적 보정. 없으면 단어를 빠뜨린다
빔을 키울수록 계속 좋아진다금방 포화된다. 논문도 중국어는 200에서 충분하다고 판단했다

§10 이해도 퀴즈

부록 · 왜 하필 "빔"인가

이 글에서 빔은 줄곧 "지금 살아남은 문장 후보 목록"이라는 뜻으로 썼습니다. 그런데 후보 목록을 왜 하필 빛줄기에 빗댔을까요. 이름의 출처는 이 글과 같은 동네 — 음성 인식입니다.

1976년, HARPY

카네기멜런의 Bruce Lowerre가 박사논문에서 HARPY 음성 인식 시스템을 만들며 붙인 이름입니다. DARPA 음성 이해 연구(SUR) 프로그램 과제였고, 상태가 1만 5천 개쯤 되는 네트워크를 매 시점 훑어야 하는데 전부 열어 볼 수는 없었습니다. 그래서 유망한 것만 남기고 나머지를 버리는 방식을 썼고, 거기에 beam search라는 이름이 붙었습니다.

원래 "빔"은 개수가 아니라 점수 창이었다

여기가 이름이 납득되는 지점입니다. Lowerre의 원래 빔은 "상위 몇 개"가 아니었습니다.

이번 시점 1등 점수에서 일정 폭 안에 드는 후보는 전부 남긴다.

그러면 매 시점 살아남은 후보들의 점수가 1등 주변으로 띠를 이루고, 그 띠가 시간축을 따라 훑고 지나갑니다. 손전등 빔이 어둠 속을 좁게 비추며 지나가는 모양이죠. "빔 폭(width)"이라는 말이 자연스러운 것도 그게 원래 진짜 폭 — 점수 창의 넓이였기 때문입니다. 개수를 세는 거였다면 폭이 아니라 개수라고 불렀을 겁니다.

그래서 §4의 top_k는 엄밀히는 빔이 아니다

beams = top_k(next, key=lambda s: s["pb"] + s["pnb"], k=beam_width)

이건 고정 폭(fixed-width) 변형입니다. 점수와 무관하게 무조건 상위 N개만 남기죠. 구현이 간단하고 메모리 상한이 보장돼서 지금은 이쪽이 사실상 표준이 됐고, §5의 500 / 200도 이 방식입니다.

다만 실전 ASR 디코더는 아직 둘 다 들고 있습니다. 예를 들어 Kaldi는 --beam(1등 대비 로그 확률 임계값 — 원래 의미)과 --max-active(활성 상태 개수 상한 — top_k)를 따로 받고, 보통은 두 개를 같이 겁니다. 어려운 구간에서는 임계값만으로 후보가 터지기 때문인데, §7에서 논문이 누적 확률 p = 0.99 말고 "최대 40자"라는 하드 리밋을 하나 더 둔 것과 정확히 같은 동기입니다.

정리하면 빔은 "가능한 경우 전부"가 아니라 그 반대 — 일부러 좁게 비춘 범위입니다. §4의 트레이스에서 매 프레임 10개 안팎을 만들어 3개만 남기던 그 top_k 한 줄이, 지수를 선형으로 바꾸는 대가로 정답을 놓칠 위험을 감수하는 유일한 지점이고요.

↩ §5 빔 크기로 돌아가기