STT(Speech-to-Text, 음성 인식)를 실서비스에 붙이면 가장 먼저 나오는 요구는 대개 하나입니다.
“우리 회사 용어를 잘 못 알아들어요. 정확도를 올려 주세요.”
이 요구를 “더 큰 모델로 바꾸자”거나 “파인튜닝을 하자”로 곧장 번역하면 대부분 실패합니다. 정확도는 하나의 손잡이가 아니라 여러 계층의 문제이기 때문입니다. 어떤 오류는 입력 오디오 문제이고, 어떤 오류는 파라미터로 해결되며, 어떤 오류는 인식 이후의 텍스트 교정으로만 안전하게 다룰 수 있습니다. 그리고 그중 일부만이 파인튜닝이 필요한 문제입니다.
이 글은 인식 후 교정(post-recognition)을 중심으로, 거기서 필요해지는 디코딩 시점 힌트(용어 사전)와 파인튜닝까지 연결해 정확도를 끌어올리는 방법과 도메인 적응(Domain Adaptation, 특정 업무 영역에 맞추는 과정) 을 다룹니다. 즉 중심축은 인식이 끝난 뒤의 교정이지만, 그 한계를 만났을 때 넘어가는 디코딩 힌트·학습 단계도 같은 흐름 안에서 함께 봅니다. 실시간 자막 파이프라인 자체(브라우저 오디오 캡처, 48kHz → 16kHz 리샘플링, Partial·Final 자막 의미, VAD와 무음·반복 환각 억제, 최후의 방어로서의 Denylist)는 별도 글에서 이미 정리했으므로 여기서는 다시 설명하지 않고 그 위에 얹습니다.
- 실시간 STT 파이프라인 설계: https://aiarchitect.tistory.com/12
- 회의 음성을 RAG로 연결하며 원본·교정본을 분리 저장하는 이야기: https://aiarchitect.tistory.com/4
이 글의 배경은 다음과 같습니다. 필자는 WebRTC 기반 실시간 STT 파이프라인(Faster-Whisper large-v3)을 상용 운영했고, 이를 서버 측 녹화 방식으로 확장해 개발·상용화 검증했습니다. 성능은 CPU와 NVIDIA DGX Spark GPU 양쪽에서 측정했고, 화자별 WebSocket 자막을 제공했습니다. 이 글에서 인용하는 정확도·지연·비용은 특정 제품의 측정값이 아니라 일반적 방법론과 정성적 설명입니다. 실제 수치는 각자의 오디오 데이터로 다시 측정해야 합니다.
기준 흐름은 다음과 같습니다. 왼쪽(실시간 파이프라인)은 companion 글의 영역이고, 이 글은 오른쪽(교정·적응)을 다룹니다.
[실시간 STT 파이프라인 — companion 글] [이 글의 영역 — 인식 후 교정·도메인 적응]
Audio → VAD → Whisper Inference → FINAL ──▶ rawText 확정
→ 한국어 정규화(띄어쓰기/숫자/날짜)
→ 고유명사·동음이의어 치환 규칙
→ 화자·문맥 기반 교정
→ displayText 확정
→ (사람 교정) Human-in-the-loop
→ 오류 카탈로그 → 규칙/사전/파인튜닝
→ 버전 추적 · 감사 · RAG/다운스트림 소비
1. 정확도는 하나의 숫자가 아니라 계층이다
“정확도를 올려 주세요”라는 요청을 받으면, 먼저 오류를 어느 계층에서 고칠 수 있는지 분해해야 합니다. 같은 “틀린 자막”이라도 원인과 처방이 다릅니다.
| 계층 | 대표 오류 | 처방 | 되돌릴 수 있나 |
|---|---|---|---|
| 입력 오디오 | 음량 부족, Clipping, 잘못된 Sample Rate | 캡처·리샘플링·정규화(companion 글) | 재녹음 없이 어려움 |
| 디코딩 파라미터 | 무음 환각, 반복, 언어 오인식 | language, beam/threshold, VAD |
즉시 |
| 인식 확률 힌트 | 고유명사 첫 등장 오인식 | Hotwords / initial_prompt | 즉시 |
| 사후 텍스트 교정 | 동음이의어, 표기 불일치, 띄어쓰기 | 정규화·치환 규칙(추적 가능) | 규칙 롤백으로 되돌림 |
| 모델 자체 | 도메인 특유 발음·억양 전반의 체계적 오류 | 파인튜닝 | 배포는 이전 체크포인트로 롤백 가능, 새 오류 수정은 재학습 필요 |
핵심 원칙은 비용과 위험이 낮은 계층부터 소진하는 것입니다. 이는 보편 법칙이라기보다 운영 휴리스틱에 가깝습니다. 필자의 경험상 파인튜닝은 값싼 계층을 먼저 검토한 뒤에 판단하는 것이 안전했고, 실제로 상당수의 “정확도 요구”는 파인튜닝 없이도 해소되는 경우가 많았습니다. 다만 도메인·데이터 상황에 따라 파인튜닝이 더 빠른 길일 수도 있으니, 아래 순서는 “먼저 배제해 볼 후보”로 읽는 것이 좋습니다.
1) 입력 오디오/파라미터 문제인지 먼저 배제
2) 공개 가능한 도메인 용어를 Hotwords/Prompt로 제한적 제공
3) 인식 후 규칙 기반 교정(추적 가능) 적용
4) 사람 교정 피드백을 수집·규칙화
5) 그래도 남는 체계적 오류에 한해 파인튜닝 검토
2. rawText는 재처리 기준, displayText는 표현이다
이 글의 모든 논의는 하나의 데이터 규칙 위에 서 있습니다. 원본 STT 결과(rawText)와 사람이 읽는 교정본(displayText)과 AI 요약은 서로 다른 계층이며, 절대 서로 덮어쓰지 않는다. 이 분리는 companion 글(회의 음성 → RAG)에서 이미 세운 원칙이며, 여기서는 교정 관점으로 확장합니다.
한 가지 분명히 해 둘 점은, rawText도 이미 모델을 거친 파생 결과라는 것입니다. 최종 원천은 어디까지나 원본 오디오이고, rawText는 그 위에서 만들어진 “원시 인식 결과이자 이후 모든 교정의 재처리 기준”입니다. 따라서 rawText를 그 자체로 “법적 증거”처럼 다루기보다, 되돌릴 수 있는 기준선으로 보존한다고 이해하는 편이 안전합니다.
| 계층 | 성격 | 변경 규칙 |
|---|---|---|
| 원본 오디오 | 최종 원천 | 변경하지 않음 |
| rawText (STT 원문) | 모델이 인식한 원시 결과·재처리 기준 | 모델·설정 버전과 함께 보존, 불변 |
| displayText (교정본) | 사람이 읽기 좋은 문장 | 규칙·버전으로 파생, 재생성 가능 |
| AI 요약 | 파생 산출물 | 어떤 교정본·프롬프트로 만들었는지 기록 |
{
"segmentId": "segment-42",
"startMs": 38120,
"endMs": 41640,
"rawText": "다음주 금요일에 에이씨엠이로 배포 합니다",
"displayText": "다음 주 금요일에 ACME로 배포합니다.",
"offsetUnit": "python_codepoint",
"corrections": [
{ "stage": "spacing", "op": "replace", "from": "다음주", "to": "다음 주",
"startOffset": 0, "endOffset": 3, "ruleId": "spacing-011" },
{ "stage": "term_substitution", "op": "replace", "from": "에이씨엠이", "to": "ACME",
"startOffset": 9, "endOffset": 14, "ruleId": "term-0007" },
{ "stage": "spacing", "op": "replace", "from": "배포 합니다", "to": "배포합니다",
"startOffset": 17, "endOffset": 22, "ruleId": "spacing-012" },
{ "stage": "punctuation", "op": "insert", "from": "", "to": ".",
"startOffset": 22, "endOffset": 22, "ruleId": "punct-003" }
],
"sttModelVersion": "public-stt-vX",
"normalizationVersion": "public-rule-v3"
}
corrections[]의 각 항목은 어느 단계(stage)에서, 어떤 연산(op)으로, 어떤 규칙(ruleId)으로 바꿨는지를 남깁니다. 띄어쓰기와 문장부호 삽입은 서로 다른 단계이므로 한 이벤트로 합치지 않고 분리합니다. offset은 반복 문자열이 있어도 적용 위치를 특정하기 위한 것이며, 단위(offsetUnit)는 데이터 계약으로 명시합니다. 다만 offset이 “어느 단계의 텍스트 기준인가”는 구현마다 달라질 수 있으므로(순차 교정은 앞 단계 출력 위에서 계산됨), 재현이 중요하면 단계별 입력·출력 해시를 함께 남기는 것이 안전합니다. 위 예시의 offset은 원 rawText 기준의 근사 표기입니다.
rawText가 살아 있으면 교정 규칙이 틀렸을 때 언제든 되돌릴 수 있습니다. rawText를 지우고 displayText만 남기면, 잘못된 치환을 발견해도 모델이 원래 무엇으로 인식했는지 복구할 수 없습니다. 이 한 가지 실수가 이후 모든 감사와 재처리를 막습니다. 물론 “원래 무엇이라 말했는가”의 최종 확인은 rawText가 아니라 원본 오디오로 돌아가야 한다는 점도 잊지 않습니다.
3. 인식 후 교정 계층의 순서
교정은 순서가 중요합니다. 뒤 단계가 앞 단계의 출력을 전제로 하기 때문입니다. 권장 파이프라인은 다음과 같습니다.
rawText
→ (a) 유니코드/공백 정규화 (NFC, 선택된 비표시 문자 제거)
→ (b) 한국어 정규화 (띄어쓰기 / 숫자 / 날짜 / 단위)
→ (c) 고유명사·약어 표기 통일 (사전 기반)
→ (d) 동음이의어 치환 (문맥·화자 조건부)
→ (e) 문장부호·문장 분리
→ (f) Denylist(차단 문구) 최종 점검
→ displayText
각 단계는 무엇을 바꿨는지(from/to)와 어떤 규칙(ruleId)으로 바꿨는지를 corrections[]에 남깁니다. 교정은 “보이지 않게 잘 고치는 것”이 목표가 아니라 “추적 가능하게 고치는 것”이 목표입니다.
주의할 점은 companion 글에서 이미 다룬 것과의 경계입니다. 실시간 Partial 자막에는 과도한 문장부호·띄어쓰기 후처리를 하지 않고, 이 교정 파이프라인은 FINAL로 확정된 rawText에만 적용합니다. Partial은 계속 바뀌므로 교정하면 화면이 요동칩니다.
4. 한국어 정규화: 띄어쓰기·숫자·날짜
한국어 STT 교정에서 가장 자주, 그리고 가장 안전하게 얻는 개선은 정규화입니다. 발음은 맞게 인식됐지만 표기 규칙이 흔들리는 경우입니다.
4.1 유니코드·공백 정규화 (가장 먼저)
한글은 조합형(Decomposed)과 완성형(Composed)이 시각적으로 같아도 코드포인트가 다릅니다. 사전 매칭·해시·비교가 어긋나는 원인이 되므로 가장 먼저 NFC로 통일합니다.
import unicodedata
import re
def normalize_unicode(text: str) -> str:
text = unicodedata.normalize("NFC", text) # 한글 자모 결합 통일
text = text.replace("\u200b", "").replace("\ufeff", "") # ZWSP, BOM 제거
text = re.sub(r"[ \t]+", " ", text).strip() # 연속 공백 축소
return text
여기서는 대표적인 비표시 문자만 다룹니다. 위 코드는 ZWSP(zero-width space, )와 BOM() 만 제거하므로, 정책 문서에는 “제어문자 전부”가 아니라 “선택된 비표시 문자 제거”로 적는 편이 정확합니다. 다른 제어문자·zero-width 계열까지 지우려면 제거 대상 집합을 명시적으로 정의해 확장합니다.
4.2 숫자·날짜·단위
STT는 “이천이십육년 칠월”처럼 읽은 대로 뱉는 경우가 많습니다. 회의록·검색에서는 숫자 표기가 통일돼야 유용합니다. 다만 모든 수사를 무조건 아라비아 숫자로 바꾸면 관용 표현(“한두 명”, “서너 개”)이 깨집니다. 규칙은 화이트리스트로 보수적으로 시작합니다.
| 입력(발화대로) | 정규화 후 | 규칙 성격 |
|---|---|---|
| 이천이십육년 칠월 삼십일 | 2026년 7월 30일 | 날짜 패턴 |
| 삼점오 퍼센트 | 3.5% | 소수·단위 |
| 오후 두시 반 | 오후 2시 30분 | 시각 |
| 한두 명 | 한두 명 | 관용 수사 → 변경 안 함 |
4.3 띄어쓰기
띄어쓰기는 형태소 분석기나 별도 교정 모델을 쓸 수 있지만, 규칙이 자동일수록 검증이 어려워집니다. 회의록처럼 감사·재처리의 기준이 되는 텍스트에서는 다음을 지킵니다.
- 원본(rawText)은 절대 손대지 않는다.
- 자동 띄어쓰기는 displayText에만 적용한다.
- 자동 교정기의 버전을
normalizationVersion으로 남긴다. - 자동 교정이 의미를 바꾸는 사례를 회귀 테스트로 관리한다.
5. 고유명사·약어 표기 통일
정규화 다음으로 안전한 개선은 표기 통일입니다. 발음은 맞았지만 표기가 갈리는 경우입니다. 예: 한 회의 안에서 “에이씨엠이 / ACME / 에이씨엠 이”가 섞여 나오면, 사람이 읽기에도 나쁘고 검색·집계도 어긋납니다.
핵심은 치환 규칙을 데이터로 관리하는 것입니다. 코드에 하드코딩하지 않습니다.
{
"ruleId": "term-0007",
"scope": "workspace",
"type": "term_substitution",
"match": { "surface": ["에이씨엠이", "에이씨엠 이", "에이 씨 엠 이"] },
"to": "ACME",
"conditions": { "boundary": "token" },
"confidence": "high",
"createdFrom": "human_feedback",
"version": 3,
"enabled": true
}
| 필드 | 목적 |
|---|---|
scope |
테넌트/워크스페이스 단위로 규칙 격리(교차 오염 방지) |
match.surface |
인식이 흔들리는 여러 표기를 함께 등록 |
conditions |
토큰 경계·앞뒤 문맥 등 오검출 방지 조건 |
confidence |
자동 적용 여부와 검토 우선순위 결정 |
createdFrom |
규칙의 출처(사람 피드백/운영자/자동 후보) |
version / enabled |
롤백과 A/B 비교, 즉시 비활성화 |
단순 문자열 전역 치환은 위험합니다. 짧은 약어를 무조건 바꾸면 일반 명사·다른 문맥까지 손상됩니다. 그래서 토큰 경계와 문맥 조건, 그리고 confidence를 둡니다. 낮은 confidence 규칙은 자동 적용하지 않고 “후보”로만 표시합니다.
6. 동음이의어·문맥 기반 교정
가장 어려운 계층입니다. 한국어에는 발음이 같고 뜻이 다른 표현이 많습니다. “배포/배표”, “결재/결제”, “연동/연통” 같은 쌍은 오디오만으로 구분되지 않을 수 있습니다. 이때는 문맥과 화자를 조건으로 삼습니다.
아래는 개념 코드입니다(실행용 아님). 실제 구현은 형태소 경계·앞뒤 문자 조건을 함께 봐야 하지만, 여기서는 “surface를 문장 안에서 찾아 위치와 함께 교정 이벤트를 만든다”는 골격만 보입니다. 이전 판본은 세그먼트 전체가 surface와 정확히 같을 때만 동작했고 치환 위치도 남기지 않았는데, 그러면 문장 안에 섞인 후보를 잡지 못합니다.
startOffset·endOffset는 이 예시에서 Python 문자열 인덱스(유니코드 코드포인트 기준)입니다. 프런트엔드가 UTF-16 인덱스를 쓰는 경우가 많으므로, 실제 시스템에서는 offset 단위를 데이터 계약으로 명시하고 경계에서 변환해야 합니다.
def resolve_homophones(segment, rules, context):
"""개념 코드(실행용 아님): 문장 안의 동음이의어 후보를 위치와 함께 찾아 교정 이벤트 생성."""
text = segment["rawText"]
events = []
for rule in rules:
if rule["type"] != "homophone":
continue
# 화자 신호: 특정 화자 역할에서만 쓰는 용어(조건이 없으면 통과)
speaker_ok = (
not rule.get("speakerRoles")
or context.get("speaker_role") in rule["speakerRoles"]
)
# 문맥 신호: 같은 세그먼트 창 안의 단서 단어(cues가 비면 이 조건은 요구하지 않음)
cue_ok = (
not rule.get("cues")
or any(cue in context.get("window_text", "") for cue in rule["cues"])
)
if not (speaker_ok and cue_ok):
continue # 확신 없으면 교정하지 않음(rawText 유지)
# 문장 안에서 각 surface의 등장 위치를 찾아 offset을 기록
for surface in rule["surfaces"]:
start = text.find(surface)
while start != -1:
end = start + len(surface)
# (개념) 실제로는 여기서 토큰/형태소 경계 검증을 추가한다
events.append({
"from": surface,
"to": rule["to"],
"startOffset": start, # Python 코드포인트 인덱스(데이터 계약으로 단위 명시)
"endOffset": end,
"ruleId": rule["ruleId"],
"confidence": rule["confidence"],
})
start = text.find(surface, end)
# (개념) 같은 위치에 여러 규칙이 겹치면 confidence·우선순위로 하나만 남기는
# 충돌 제거 단계를 여기 둔다. 이 골격에서는 생략한다.
return events # 빈 리스트면 교정 없음
원칙은 확신이 없으면 고치지 않는다입니다. 잘못된 교정은 “아무것도 안 한 것”보다 나쁩니다. 원문 발음이 맞았는데 문맥 교정이 틀리면, 사람이 검토할 때 근거가 사라지기 때문입니다. 동음이의어 교정은 confidence를 낮게 두고, 처음에는 “후보 강조 표시”로만 노출한 뒤 사람 피드백이 쌓이면 승격합니다.
7. 용어 사전(1): Hotwords와 initial_prompt는 무엇인가
여기까지가 인식 이후의 교정이었다면, 용어 사전은 인식 확률에 개입하는 방법입니다. 다만 라이브러리마다 제공하는 인자가 다릅니다. faster-whisper는 hotwords와 initial_prompt를 지원하고, OpenAI Whisper는 initial_prompt와 carry_initial_prompt를 지원합니다. 이 글에서 비교하는 OpenAI Whisper와 faster-whisper 중에서는 faster-whisper만 hotwords 인자를 제공합니다(다른 ASR 엔진에도 hotword·contextual biasing 기능은 있으므로, “hotwords는 오직 faster-whisper뿐”이라는 뜻은 아닙니다). 따라서 이 글에서 말하는 “hotwords 인자”는 두 구현 중 faster-whisper 통로를 전제로 읽어야 합니다.
- Hotwords (우선 인식 단어): faster-whisper 공식 문서상 “Hotwords/hint phrases to the model. Has no effect if prefix is not None.” 즉 모델에 힌트 구를 주되,
prefix가 설정돼 있으면 효과가 없습니다. 비교 대상인 OpenAI Whisper에는 대응하는hotwords인자가 없습니다. - initial_prompt (초기 프롬프트, 두 라이브러리 공통): “Optional text string or iterable of token ids to provide as a prompt for the first window.” 일반
WhisperModel.transcribe()에서는initial_prompt가 첫 번째 창(first window) 을 시드하는 텍스트(또는 토큰 id)이며, 이후 창으로의 전파는condition_on_previous_text·프롬프트 리셋 설정에 따라 달라집니다. “각 처리 창(each window)에 제공”이라는 표현은 배치 추론(batched inference) 문서의 설명이므로, 배치 파이프라인을 쓸 때만 그대로 적용됩니다. OpenAI Whisper에서는carry_initial_prompt로 초기 프롬프트를 이후 창까지 이어 붙일지 제어합니다.
OpenAI Whisper 쪽 공식 가이드(Cookbook)는 프롬프트로 사람·제품·회사의 철자(spelling)를 유도할 수 있고, 프롬프트가 실제 이전 발화일 필요는 없다(가상의 프롬프트로 표기·스타일을 유도)고 설명합니다. 동시에 두 가지 한계를 분명히 합니다.
- 프롬프트에는 토큰 상한이 있습니다. OpenAI 프롬프팅 가이드 기준 최대 224토큰이며, faster-whisper의 실제 수용 범위는 버전·디코더 컨텍스트 구성에 따라 확인해야 합니다. 긴 용어집을 통째로 넣을 수 없습니다.
- 이 기법은 “특별히 신뢰할 만하지는 않다(not especially reliable)” 고 공식 문서가 직접 명시합니다. 상황에 따라 도움이 될 뿐입니다.
segments, info = model.transcribe(
audio,
language="ko",
beam_size=5,
hotwords="ACME, DGX Spark, WebRTC", # 짧은 도메인 용어만 (faster-whisper의 hotwords 인자)
# 또는:
# initial_prompt="회의에서 다음 용어가 등장합니다: ACME, DGX Spark, WebRTC.",
)
즉 용어 사전은 인식 확률을 조금 밀어 주는 힌트이지, 모델을 학습시키는 것이 아닙니다. 이 차이가 다음 절의 의사결정 기준을 가릅니다.
8. 용어 사전(2): 확률 조정 vs 학습, 그리고 한계
용어 사전을 “정확도의 만능 손잡이”로 오해하면 곧 벽을 만납니다. 성격과 한계를 표로 정리합니다.
| 구분 | Hotwords / initial_prompt | 파인튜닝 |
|---|---|---|
| 개입 지점 | 디코딩 시점 확률 힌트 | 모델 가중치 |
| 즉시성 | 모델 재학습 불필요(설정 반영·프로세스 재시작은 필요할 수 있음) | 학습·평가·배포 사이클 필요 |
| 용량 | 짧게(프롬프트 토큰 상한) | 대량 데이터 반영 |
| 신뢰성 | 정성적, 보장 안 됨 | 데이터 품질에 비례 |
| 위험 | 프롬프트 오염·유도 편향, 과도한 유도 | 과적합, 일반 성능 저하 |
| 되돌리기 | 설정 한 줄 | 이전 체크포인트로 롤백 |
안전한 용어 목록의 조건
- 짧고 명확한 도메인 명사/약어만 넣습니다(사람 이름, 제품·기술 약어 등).
- 공개 가능한 용어만 넣습니다. 비밀번호·내부 코드명·개인정보는 넣지 않습니다.
- 목록은 버전 관리하고, 어떤 용어를 넣었을 때 어떤 오류가 줄었는지 오류 카탈로그(11절)로 추적합니다.
프롬프트 오염·유도 편향(prompt-induced bias) 위험
먼저 용어를 분명히 합니다. Whisper의 프롬프트는 GPT처럼 명령을 실행하는 프롬프트가 아니므로, 여기서 말하는 위험은 이른바 “prompt injection(명령 주입)”이 아닙니다. Whisper 프롬프트는 출력의 표기 스타일과 토큰 분포를 유도하는 문맥일 뿐이며, 공식 가이드도 GPT 프롬프팅과 성격이 다르다고 설명합니다. 따라서 정확한 명칭은 프롬프트 오염·유도 편향(prompt-induced bias) 입니다.
initial_prompt에 아직 확정되지 않은 Partial 전체나 이전 오류 문장을 무제한 누적하면, 잘못된 문구가 다음 창으로 증폭될 수 있습니다(이 메커니즘의 실시간 측면은 companion 글에서 다룸). 또한 프롬프트에 실제로 발화되지 않은 목표 문장을 넣으면, 모델이 그 문장 쪽으로 토큰 분포가 치우쳐 출력 편향·환각 위험을 높일 수 있습니다. 공식 가이드도 가상의 프롬프트로 표기·스타일을 유도할 수 있다고 소개하면서, 동시에 그 신뢰성은 높지 않다고 분명히 합니다. 프롬프트로 표기를 유도하는 것과 “정답 문장을 통째로 주입해 그렇게 들린 것처럼 만드는 것”은 위험도가 다릅니다.
권장 (안전한 힌트)
- 짧은 언어 힌트 (language="ko")
- 제한된 공개 용어 목록
- (필요 시) 최근 확정된 문장 일부
비권장 (오염 위험)
- 아직 바뀔 수 있는 Partial 전체
- 사용자 비밀정보 / 개인정보
- 이전 오류 문장을 무제한 누적
- 실제 발화되지 않은 "정답" 문장 주입
정리하면, 용어 사전은 첫 등장 고유명사 표기를 부드럽게 유도하는 데 좋고, 체계적·반복적 오류를 근본적으로 없애 주지는 못합니다. 후자는 사후 교정 규칙 또는 파인튜닝의 영역입니다.
9. 파라미터 튜닝 vs 파인튜닝: 의사결정 트리
가장 자주 받는 질문입니다. “파인튜닝을 해야 하나요?” 경험적으로는 “먼저 값싼 계층부터 확인해 보자”가 유용한 출발점입니다(항상 정답이라는 뜻은 아닙니다). 다음 트리를 따릅니다.
Q1. 오류가 특정 오디오(음질/음량/잡음)에서만 나는가?
└ 예 → 입력·리샘플링·VAD 문제. 파인튜닝 대상 아님. (companion 글)
└ 아니오 ↓
Q2. 오류가 소수의 고유명사/약어 첫 등장인가?
└ 예 → Hotwords/initial_prompt + 사후 치환 규칙으로 충분한 경우가 많음.
└ 아니오 ↓
Q3. 오류가 표기/띄어쓰기/숫자/동음이의어처럼 "텍스트 층"에서 결정되는가?
└ 예 → 사후 교정 규칙(추적 가능). 파인튜닝보다 값싸고 되돌리기 쉬움.
└ 아니오 ↓
Q4. 오류가 특정 발음/억양/도메인 발화 전반에 걸친 "체계적" 오류인가?
그리고 그 오류를 담은 라벨링 데이터를 충분히 확보할 수 있는가?
└ 아니오 → 데이터부터 모은다. 그때까지는 규칙/사전으로 버틴다.
└ 예 → 파인튜닝 후보. 단, 아래 트레이드오프를 먼저 계산한다.
파인튜닝이 정당화되는 전형적 신호는 다음과 같습니다.
- 규칙/사전으로는 못 잡는 음향적 오류가 반복된다(특정 억양, 전문 발음, 특유의 말투).
- 그 오류가 넓은 범위에서 발생해 규칙 개수가 계속 늘고 서로 충돌하기 시작한다(“규칙 수백 개”는 고정된 임계값이 아니라, 유지보수가 버거워지는 신호의 예시일 뿐입니다. 실제 한계는 규칙의 성격·충돌 빈도·팀 여력에 달려 있습니다).
- 품질 좋은 라벨 데이터(오디오+정답 전사)를 지속적으로 확보·검증할 수 있다. 파인튜닝 성패는 데이터 양만으로 결정되지 않으며, 충분한 양과 함께 대표성·정합성·라벨 품질·평가 설계가 모두 중요합니다. 공개 가이드가 제한된 분량으로 효과를 보인 예제(예: Hugging Face의 힌디어 Common Voice)는 특정 언어·데이터셋 사례일 뿐, 한국어나 모든 도메인에 그대로 일반화되는 기준은 아닙니다.
파인튜닝의 트레이드오프
| 항목 | 규칙/사전 | 파인튜닝 |
|---|---|---|
| 데이터 | 소량, 규칙 예시 | 충분한 양 + 대표성·정합·라벨 품질이 모두 관건 |
| 비용 | 낮음(엔지니어 시간) | 학습·평가 인프라 + 반복 사이클 |
| 위험 | 국소적, 롤백 쉬움 | 일반 성능 저하(regression) 가능 |
| 유지보수 | 규칙 축적 시 관리 부담 | 모델 버전·데이터셋 계보 관리 |
| 효과 범위 | 아는 오류만 | 학습 분포 전반 |
Hugging Face의 공개 가이드는 Transformers로 Whisper를 임의 언어 ASR 데이터셋에 파인튜닝하는 절차를 단계별로 제공하며, 한국어 데이터셋 예제도 커뮤니티에 다수 존재합니다. 다만 파인튜닝은 정답 라벨의 품질에 성패가 달려 있고, 그 라벨의 상당 부분은 결국 다음 절의 사람 교정 피드백 루프에서 나옵니다. 즉 “파인튜닝을 하려면 먼저 좋은 피드백 루프가 있어야 한다”는 순서가 됩니다.
또 하나 실무에서 놓치기 쉬운 점은 배포 형식입니다. faster-whisper는 추론 엔진(CTranslate2 기반)이므로, Transformers로 학습한 파인튜닝 모델을 그대로 서빙하지 못합니다. 실제 배포 때는 추론 엔진이 요구하는 형식으로 변환하고, 변환 후 산출물이 학습 시점과 동일하게 동작하는지 별도 회귀 검증을 거쳐야 합니다.
10. 사람 교정 피드백 루프(Human-in-the-loop)
정확도를 지속적으로 올리는 엔진은 결국 사람이 고친 결과를 다시 시스템으로 되먹이는 루프입니다. 이때 설계를 잘못하면 개인정보가 새거나, 소수의 잘못된 수정이 규칙을 오염시킵니다.
10.1 명시적 수집 vs 암시적 수집
| 방식 | 예 | 신뢰도 | 주의 |
|---|---|---|---|
| 명시적(explicit) | 사용자가 자막을 직접 수정 | 높음 | 수정 의도·문맥까지 저장 |
| 암시적(implicit) | 복사한 구간, 검색해서 클릭한 결과, 재생 반복 구간 | 낮음~중간 | 신호일 뿐, 정답 아님 |
암시적 신호는 “여기가 중요/의심스럽다”는 힌트이지 정답이 아닙니다. 정답으로 승격하려면 명시적 확인이 필요합니다.
10.2 개인정보 분리 저장
피드백에는 실제 발화 내용이 담기므로 개인정보가 포함될 수 있습니다. 원칙은 교정 신호와 원문을 분리하는 것입니다.
{
"feedbackId": "opaque-feedback-id",
"tenantScope": "opaque-tenant",
"segmentRef": "opaque-segment-ref",
"feedbackTargetRef": "opaque-segment-ref#offset:12-17",
"from": "에이씨엠이",
"to": "ACME",
"type": "term_substitution",
"source": "explicit_user_edit",
"editorRole": "reviewer",
"opaqueEditorRef": "opaque-editor-ref",
"createdAt": "2026-07-30T00:00:00Z"
}
feedbackTargetRef는 “같은 세그먼트의 같은 위치에 대한 교정”을 식별하는 키입니다. segmentRef만 쓰면 한 세그먼트 안의 서로 다른 교정 위치가 뭉뚱그려지므로, 세그먼트 내 offset(또는 토큰 범위)까지 포함해 “한 편집자가 같은 자리에 낸 교정”을 하나로 묶습니다.
editorRole은 “검수자/일반 사용자” 같은 역할일 뿐 편집자를 유일하게 식별하지 못합니다. “서로 다른 편집자 수”를 세려면 별도의 불투명 식별자 opaqueEditorRef가 필요합니다(원문 실명이 아니라 가명화된 참조).
- 피드백 레코드에는 가능하면 오류 쌍(from/to)만 남기고, 문장 전체·화자 실명·회의 제목은 피합니다.
- 원문 전체가 필요한 검토는 권한이 통제된 별도 저장소에서만 조회하고, 규칙 생성 파이프라인에는 from/to 요약만 흘려보냅니다.
- 보존 기간·삭제 전파(원본 삭제 시 파생 피드백을 동의·계약·법적 보존 정책에 따라 삭제 또는 연결 해제)를 함께 정의합니다.
- 주의:
opaque-*식별자는 익명화(anonymization)가 아니라 가명화(pseudonymization) 입니다. 매핑 테이블이 있으면 원본으로 되돌릴 수 있고, from/to 자체에 사람 이름·직책·고객 코드가 담기면 여전히 민감정보일 수 있습니다. 따라서 “오류 쌍만 저장하면 개인정보가 분리된다”가 아니라, 오류 쌍에도 최소 수집·접근통제·보존기간·삭제 정책을 동일하게 적용해야 합니다.
10.3 거짓 피드백 방어: N회 일치 후 규칙화
한 명의 한 번 수정으로 전역 규칙을 만들면, 오타·실수·악의적 수정이 곧바로 파이프라인을 오염시킵니다. 규칙화 조건을 둡니다.
from collections import defaultdict
# 소스별 신뢰 가중치(예시 값 — 실제로는 데이터로 조정)
SOURCE_WEIGHT = {
"designated_reviewer_edit": 3.0, # 지정 검수자 명시적 수정
"explicit_user_edit": 1.0, # 일반 사용자 명시적 수정
"implicit_signal": 0.2, # 복사/재생 반복 등 암시적 신호
"auto_candidate": 0.2, # 모델 제안(검토 필수)
}
WEIGHT_THRESHOLD = 4.0 # 승격에 필요한 누적 가중치 하한(예시)
HIGH_CONFIDENCE = 8.0 # 이 이상이면 confidence=high(예시)
def promote_to_rule(feedbacks, min_agreement=3,
min_distinct_editors=2, min_distinct_targets=2):
"""같은 from→to 교정이 충분히 반복·다수·여러 위치에서 확인되면 규칙 후보로 승격.
순서가 중요하다:
(1) 알 수 없는 source는 신뢰 계산에서 제외한다.
(2) 먼저 (테넌트, 교정 위치, 편집자)별로 '가장 최근 제출'만 남긴다.
- 같은 사람이 같은 자리를 A→B에서 A→C로 고치면 최신 것만 반영된다.
(3) 그 후 (테넌트, from, to, type)로 버킷화한다. 테넌트는 절대 섞지 않는다.
"""
# (1) 가중치가 정의된 source만 사용(오탈자/미지 source가 카운트 부풀리는 것 방지)
known = [fb for fb in feedbacks if fb["source"] in SOURCE_WEIGHT]
# (2) (테넌트, 교정 위치, 편집자)별 최신 제출 선택 — createdAt 기준
latest = {}
for fb in known:
key = (fb["tenantScope"], fb["feedbackTargetRef"], fb["opaqueEditorRef"])
cur = latest.get(key)
if cur is None or fb["createdAt"] > cur["createdAt"]:
latest[key] = fb # 입력 순서가 아니라 createdAt로 '마지막 제출' 결정
# (3) 테넌트를 버킷 키에 포함해 교차 테넌트 집계를 차단
buckets = defaultdict(list)
for fb in latest.values():
buckets[(fb["tenantScope"], fb["from"], fb["to"], fb["type"])].append(fb)
candidates = []
for (tenant, frm, to, typ), items in buckets.items():
editors = {i["opaqueEditorRef"] for i in items}
targets = {i["feedbackTargetRef"] for i in items}
weighted = sum(SOURCE_WEIGHT[i["source"]] for i in items)
# 서로 다른 편집자 + 서로 다른 교정 위치를 모두 요구:
# 한 자리를 여러 명이 고친 것만으로 N회 반복이 충족되지 않게 한다.
if (len(items) >= min_agreement
and len(editors) >= min_distinct_editors
and len(targets) >= min_distinct_targets
and weighted >= WEIGHT_THRESHOLD):
candidates.append({
"scope": tenant, # 규칙은 발견된 테넌트 스코프로만 생성
"from": frm, "to": to, "type": typ,
"support": len(items),
"distinctEditors": len(editors),
"distinctTargets": len(targets),
"confidence": "high" if weighted >= HIGH_CONFIDENCE else "medium",
})
return candidates # 사람 검토 후 최종 enable
핵심은 집계 전에 격리와 중복 제거를 끝내는 것입니다.
- 테넌트 격리: 버킷 키에
tenantScope를 넣어 한 고객의 피드백이 다른 고객 규칙을 만들지 못하게 하고, 승격된 후보에도scope를 실어 규칙이 그 테넌트에만 적용되게 합니다. - 최신 제출 선택: “마지막 제출”은 입력 리스트 순서가 아니라
createdAt기준입니다. 같은 편집자가 같은 자리를 다시 고치면(A→B 후 A→C) 최신 것만 반영돼, 철회된 옛 교정이 버킷에 남지 않습니다. - 위치 다양성: 편집자 수(
editorRole이 아니라opaqueEditorRef로 셈)뿐 아니라 서로 다른 교정 위치(feedbackTargetRef) 수도 함께 요구합니다. 한 세그먼트의 한 자리를 여러 명이 고친 것만으로는 “반복되는 패턴”이라 볼 수 없기 때문입니다. - 미지 source 배제:
SOURCE_WEIGHT에 없는 source는 건수·편집자 수 계산에서 아예 제외합니다.
SOURCE_WEIGHT·WEIGHT_THRESHOLD·HIGH_CONFIDENCE와 세 개의 min_* 값은 예시일 뿐, 실제 값은 데이터로 조정합니다.
10.4 소스별 가중치
모든 피드백이 같은 무게일 수 없습니다. 소스에 따라 가중치를 다르게 줍니다.
| 소스 | 예시 가중치(개념) | 근거 |
|---|---|---|
| 지정 검수자 명시적 수정 | 가장 높음 | 책임과 훈련이 있는 교정 |
| 일반 사용자 명시적 수정 | 중간 | 유효하나 편차 큼 |
| 암시적 신호(복사/재생 반복) | 낮음 | 관심 신호일 뿐 |
| 자동 후보(모델 제안) | 낮음, 검토 필수 | 순환 오류 방지 |
가중치·임계값의 구체 숫자는 데이터로 조정할 값이며, 여기서는 개념적 순서만 제시합니다.
11. 오류 카탈로그: 개선의 회계 장부
규칙·사전·파인튜닝 어느 쪽이든, 무엇이 개선을 만들었는지 추적하지 못하면 다음 결정을 내릴 수 없습니다. 그래서 오류 카탈로그(error catalog) 를 둡니다. 이는 “어떤 종류의 오류가, 어떤 빈도로, 어떤 처방으로 줄었는지”의 장부입니다.
{
"errorClass": "proper_noun_first_mention",
"example": { "raw": "에이씨엠이", "expected": "ACME" },
"frequencyBucket": "high",
"candidateFix": ["hotword", "term_substitution"],
"appliedFix": "term_substitution",
"fixRef": "term-0007",
"status": "resolved",
"recheckAfter": "next_baseline"
}
| 오류 클래스(예) | 처방 계층 |
|---|---|
| 무음/잡음 환각 | 입력·VAD·디코더 게이트 (companion 글) |
| 고유명사 첫 등장 | Hotwords + 사후 치환 |
| 표기 불일치 | 사후 치환 규칙 |
| 띄어쓰기/숫자/날짜 | 정규화 규칙 |
| 동음이의어 | 문맥·화자 조건부 교정 |
| 특정 발음/억양 전반 | 파인튜닝 후보 |
카탈로그에는 실제 발화 문장·사람 이름·회의 제목을 넣지 않고, 오류 클래스와 최소한의 예시만 남깁니다. 이 예시도 완전한 익명화가 아니라 가명화 수준일 수 있으므로, 민감정보로 간주해 접근통제·보존기간을 동일하게 적용합니다.
12. 원본 vs 교정본 관리: 버전·감사·다운스트림
교정과 적응을 반복하면, “지금 이 자막은 어떤 규칙·모델 버전으로 만들어졌나?”라는 질문이 반드시 나옵니다. 이 질문에 답하지 못하면 감사도, 재생성도, 롤백도 불가능합니다.
권장 데이터 모델은 다음과 같습니다.
{
"segmentId": "segment-42",
"audioRef": "opaque-audio-ref",
"startMs": 38120,
"endMs": 41640,
"raw": {
"text": "다음주 금요일에 에이씨엠이로 배포 합니다",
"sttModelVersion": "public-stt-vX",
"decodeParamsVersion": "public-decode-v2"
},
"display": {
"text": "다음 주 금요일에 ACME로 배포합니다.",
"normalizationVersion": "public-rule-v3",
"appliedRules": ["term-0007", "spacing-011"],
"generatedAt": "2026-07-30T00:00:00Z",
"generatedBy": "auto_correction"
},
"humanReview": {
"status": "confirmed",
"reviewer": "opaque-reviewer-ref"
}
}
여기서 sttModelVersion은 large-v3 같은 이름만으로는 부족합니다. 재현·감사를 하려면 모델 revision/digest, 추론 엔진 버전(예: faster-whisper·CTranslate2 버전), 디코딩 파라미터 스냅샷, 오디오 전처리 버전까지 함께 기록해야 “같은 입력에 같은 rawText”가 재현됩니다. 위 예시의 public-* 값은 이런 조합을 가리키는 불투명 버전 라벨이라고 이해하면 됩니다.
다운스트림은 어느 버전을 쓰나
이 결정을 명시하지 않으면 RAG·요약·검색이 제각기 다른 버전을 소비해 결과가 어긋납니다. 권장 원칙:
| 소비자 | 소스 버전 | 이유 |
|---|---|---|
| 화면 자막·회의록 | displayText 최신 | 사람 가독성 |
| AI 요약 | displayText(확정본) | 정규화된 표기가 요약 품질에 유리 |
| RAG 색인 | displayText, 단 안정 chunkId + contentVersion 메타데이터 |
재색인·중복 방지(companion RAG 글) |
| 감사·근거 조회 | rawText + audioRef | 최종 증거는 항상 원문·원음 |
| 파인튜닝 라벨 | referenceText(전사 지침 통과본)만 |
학습 라벨은 “읽기 좋은 문장”이 아니라 “들린 대로의 정답 전사” |
즉 읽기·요약·검색은 교정본을, 최종 근거는 원문·원음을, 학습 라벨은 별도의 검증 전사 계층을 씁니다. 교정 규칙이나 모델을 바꾸면 displayText를 재생성할 수 있어야 하고, 재생성 시 버전이 바뀌므로 다운스트림이 재색인 여부를 판단할 수 있습니다.
RAG 색인에서 주의할 점은 버전을 chunkId 안에 섞어 넣지 않는 것입니다. 버전을 ID에 붙이면 재생성 때마다 새 ID가 생겨 오히려 같은 내용이 여러 벡터로 중복 색인됩니다. 대신 논리적으로 안정된 chunkId(예: segmentId 기반)를 유지하고, 별도의 contentVersion 메타데이터로 버전을 표현합니다. 재생성 시에는 (1) 같은 chunkId의 이전 벡터를 삭제·비활성화하고 새 버전으로 덮어쓰거나, (2) 모든 버전을 남기되 검색 시 currentVersion 필터로 최신본만 조회하도록 합니다. 이렇게 해야 “안정 ID + 버전 메타데이터 + 이전 색인 정리”가 성립해 중복이 실제로 방지됩니다.
한편 화면 자막은 자동 교정 최신본을, AI 요약은 사람 확정본을 쓰도록 되어 있는데, 이는 상태별 정책을 명시해야 어긋나지 않습니다. 예를 들어 “displayText는 자동 생성 즉시 화면에 노출하되, humanReview.status가 confirmed가 되기 전에는 AI 요약·RAG 색인 등 다운스트림에서 소비하지 않는다”처럼, 소비자별로 어떤 상태의 displayText를 쓰는지를 규칙으로 고정합니다.
displayText를 학습 라벨로 자동 승격하면 안 되는 이유
흔한 착각은 “사람이 확정한 displayText가 곧 파인튜닝 라벨”이라는 것입니다. 정확히 말하면, displayText는 전사 지침 준수 여부가 보장되지 않기 때문에 학습 라벨로 자동 승격해서는 안 됩니다. 문제는 아래 요소들이 “나쁘다”는 것이 아니라(띄어쓰기·문장부호·고유명사 정규화는 ASR 라벨 정책의 일부가 될 수도 있습니다), 그 정책이 displayText 생성 시점에 통제·기록되지 않는다는 데 있습니다.
- 띄어쓰기·문장부호·고유명사 정규화(라벨 정책에 포함될 수도, 아닐 수도 있음)
- 실제 음성에 없는 의미 보충·문장 재작성(생략된 주어 복원 등)
- 동음이의어의 의미 교정(들린 소리가 아니라 뜻으로 고른 표기)
- 평가용 정규화와 학습용 정규화가 서로 불일치할 위험
정리하면, 동일한 텍스트라도 검증을 통과해 referenceText로 승인되면 학습에 쓸 수 있지만, displayText를 자동으로 라벨로 밀어 넣어서는 안 됩니다. 그래서 학습에는 별도의 verifiedTranscript(또는 referenceText) 계층을 둡니다. 이는 명시적인 전사 지침(무엇을 정규화하고 무엇을 그대로 두는지, 비발화 소리·간투사·중복을 어떻게 표기하는지)을 통과한 텍스트로, “음성에 실제로 들린 것”을 기준으로 만듭니다.
{
"segmentId": "segment-42",
"referenceText": "다음 주 금요일에 ACME로 배포합니다",
"transcriptionGuidelineVersion": "asr-label-guide-v2",
"derivedFrom": "human_review",
"eligibleForTraining": true
}
displayText는 사람이 읽는 표현, referenceText는 학습·평가용 정답 전사입니다. 둘은 같은 문자열일 수도 있지만 성격이 다르며, 학습·WER/CER 산정에는 전사 지침 검증을 통과한 referenceText만 사용합니다(검증을 통과했다면 displayText와 동일 문자열이어도 무방합니다).
13. 도메인 적응 사이클(전체 그림)
지금까지의 계층을 하나의 반복 사이클로 묶으면 다음과 같습니다. 이것이 이 글의 결론적 프레임입니다.
(1) Baseline 측정
- 개발·회귀 세트 + 미사용 홀드아웃 세트로 현재 정확도를 측정(개념: WER/CER)
- 실시간이면 지연·변경률도 함께(companion 글 지표)
│
(2) 오류 카탈로그화
- 개발 세트의 오류를 "클래스"로 분류(무음환각/고유명사/표기/동음이의어/발음…)
- 홀드아웃 세트의 오류는 들여다보지 않음(평가 누수 방지)
│
(3) 값싼 계층부터 처방
- 입력/파라미터 → Hotwords/Prompt → 사후 규칙/사전
│
(4) 사람 교정 피드백 수집·규칙화
- 명시/암시 수집 → N회 일치·다수 확인 → 규칙 승격
│
(5) 재측정
- 개발 세트로 회귀를 걸러내고, 최종 개선 주장은 홀드아웃 세트로 확인
│
(6) 충분한 데이터가 쌓이면 파인튜닝 검토
- 규칙으로 못 잡는 체계적 음향 오류에 한해
│
└────────────▶ 다시 (1)로 (사이클 반복)
WER·CER는 개념으로만
정확도 지표로 WER(Word Error Rate, 단어 오류율) 와 CER(Character Error Rate, 문자 오류율) 를 씁니다. 개념만 정리합니다.
- WER: (치환+삭제+삽입) / 참조 단어 수. 한국어에도 널리 쓰이지만, 띄어쓰기·토큰화 정책에 크게 영향받으므로 정책을 고정하지 않으면 값이 흔들립니다.
- CER: 문자 단위 편집거리 기반. 한국어처럼 띄어쓰기가 흔들리는 언어에서는 WER를 CER와 함께 보는 편이 안전합니다.
두 지표 모두 한 숫자로 품질을 대표하지 못합니다. 고유명사 하나가 검색·업무에 치명적이어도 전체 WER는 거의 안 움직입니다. 그래서 “전체 WER”와 별개로 오류 클래스별 지표(예: 고유명사 정확도, 동음이의어 정확도)를 따로 봅니다. 이 글에서는 구체 수치를 제시하지 않으며, 실제 값은 각자의 도메인 오디오로 측정해야 합니다.
또한 WER/CER를 재현하려면 평가 전처리 정책을 고정·기록해야 합니다. 문장부호·대소문자·띄어쓰기·숫자 표기를 어떻게 정규화했는지 남기지 않으면, 같은 오디오·모델이라도 값이 달라져 비교가 무의미해집니다.
자동 교정 전용 지표
WER/CER는 “전사가 얼마나 정확한가”를 보지만, 자동 교정 자체가 잘 작동하는가는 다른 질문입니다. “확신이 없으면 고치지 않는다”는 이 글의 원칙을 측정하려면 교정 전용 지표가 필요합니다.
| 지표 | 정의(개념) | 무엇을 감시하나 |
|---|---|---|
| 교정 정밀도(precision) | 자동 교정한 것 중 옳게 고친 비율 | 잘못된 교정이 섞이는가 |
| 오교정률 | 원문이 맞았는데 잘못 바꾼 비율 | “안 한 것만 못한 교정” |
| 적용률(coverage) | 고칠 수 있는 것 중 실제 교정한 비율 | 너무 소극적이지 않은가 |
| 보류율 | 확신 부족으로 후보로만 표시한 비율 | 사람 검토로 넘긴 양 |
핵심은 오교정률을 낮게 유지하면서 적용률을 올리는 것이고, 확신이 낮은 교정은 자동 적용 대신 보류(후보)로 돌려 보류율로 관리합니다. 이 지표들도 오류 클래스별로 나눠 보면 어떤 계층의 교정이 위험한지 드러납니다.
14. Baseline과 회귀 방지: 평가 세트를 분리하지 않으면 개선도 착시다
“정확도가 올랐다”를 주장하려면 변하지 않는 측정 기준이 있어야 합니다. 흔한 실수는 규칙을 추가하며 매번 다른 예시로 확인하는 것입니다. 그러면 A를 고치며 B를 망가뜨려도 알 수 없습니다.
그런데 여기에는 더 미묘한 함정이 하나 있습니다. 같은 세트를 보고 규칙을 만든 뒤 그 세트에서 개선을 증명하면, 평가 누수(evaluation leakage)입니다. 평가 세트의 오류를 직접 보고 규칙을 작성했으니, 그 세트에서 점수가 오르는 것은 당연하고 일반화된 개선을 주장할 수 없습니다. 그래서 세트를 최소 두 개로 분리합니다.
[개발·회귀 세트]
- 오류 분석과 규칙 작성에 사용
- 새 규칙이 알려진 회귀를 일으키지 않는지 빠르게 점검
- 대표 발화 · 고유명사 · 숫자/날짜 · 동음이의어 · 무음/잡음 케이스를 포함
[미사용 홀드아웃 평가 세트]
- 규칙·프롬프트·파인튜닝 결정에 직접 사용하지 않음(오류를 들여다보지 않음)
- 최종 "정확도가 올랐다" 주장에만 사용
- 가능하면 화자·회의 단위로 분리해 개발 세트와 겹치지 않게 함
[공통 규칙]
- 개인정보를 제거하거나 동의된 데이터만 사용
- 버전 고정, 변경 시 세트 버전을 올림
- 평가 전처리 정책(문장부호·대소문자·띄어쓰기·숫자 표기 정규화)을 함께 고정·기록
[회귀 게이트]
- 새 규칙/모델은 두 세트 모두에서 "오류 클래스별로" 비교
- 개발 세트로 회귀를 빠르게 걸러내고, 최종 판단은 홀드아웃으로 확인
- 특정 클래스가 좋아지고 다른 클래스가 나빠지면 트레이드오프를 명시적으로 결정
규칙 하나를 추가할 때마다 “이 규칙이 어떤 클래스를 개선하고 어떤 클래스를 후퇴시키는가”를 고정 세트로 답할 수 있어야 하고, “일반화되는 개선인가”는 규칙을 만들 때 보지 않은 홀드아웃 세트로만 답할 수 있어야 합니다. 이 분리가 없으면 규칙은 늘어나기만 하고 품질은 제자리이거나 나빠집니다.
또한 평가 전처리를 고정·기록하지 않으면 WER/CER 비교가 재현되지 않습니다. 같은 오디오·같은 모델이라도 문장부호를 지웠는지, 대소문자를 접었는지, 숫자를 어떻게 표기했는지에 따라 수치가 달라지기 때문입니다. 그래서 평가 세트에는 전처리 정책 버전을 함께 묶어 둡니다.
15. 교정 규칙 거버넌스: 규칙도 코드처럼 관리한다
규칙과 용어 사전은 곧 또 하나의 코드베이스입니다. 방치하면 서로 충돌하고, 아무도 왜 있는지 모르는 규칙이 쌓입니다.
| 거버넌스 항목 | 원칙 |
|---|---|
| 소유권 | 각 규칙에 출처(createdFrom)와 책임자 |
| 스코프 | 테넌트/워크스페이스 격리(교차 오염 금지) |
| 충돌 | 같은 surface에 상충 규칙이 있으면 confidence·우선순위로 결정 |
| 비활성화 | enabled=false로 즉시 끌 수 있어야 함(삭제보다 비활성화 선호) |
| 감사 | 언제 누가 규칙을 켰고, 어떤 세그먼트에 적용됐는지 |
| 만료 | 파인튜닝으로 흡수된 규칙은 후보로 강등·정리 |
특히 Denylist(차단 문구 목록) 는 companion 글에서 강조했듯 최후의 방어입니다. Denylist만으로는 새로운 환각을 못 막고 실제 발화까지 지울 수 있습니다. 이 글의 교정 규칙과 Denylist는 목적이 다릅니다. 교정 규칙은 “표기를 통일/수정”하고, Denylist는 “근거 없는 반복 문구를 제거”합니다. 둘을 섞지 않습니다.
16. 개인정보와 보안 경계
교정·피드백·적응은 모두 실제 발화 내용을 다루므로 개인정보 노출 위험이 큽니다.
- 오류 쌍 최소 저장: 피드백·카탈로그·지표에는 문장 전체 대신 from/to와 오류 클래스만 남깁니다. 단, 오류 쌍 자체도 사람 이름·고객 코드를 담으면 민감정보이고
opaque-*는 가명화일 뿐이므로, 오류 쌍에도 최소 수집·접근통제·보존기간·삭제 정책을 동일하게 적용합니다. - 로그/메트릭 라벨 금지: 실제 발화 텍스트·사람 이름·회의 제목을 로그와 메트릭 Label에 넣지 않습니다.
modelVersion,language,errorClass같은 집계 가능한 비민감 차원을 씁니다. - 테넌트 격리: 규칙·사전·피드백은 테넌트 스코프로 격리합니다. 한 고객의 용어가 다른 고객 자막을 교정하면 안 됩니다.
- 삭제 전파: 원본 삭제 시 파생된 교정본·피드백·규칙 근거도 전파 삭제 대상인지 정의합니다. 다만 “무조건 삭제”가 정답은 아닙니다. 법적 보존 의무나 비식별 집계 예외가 있을 수 있으므로, 동의·계약·법적 보존 정책에 따라 삭제하거나 연결을 해제(가명 참조 파기)하는 방식을 함께 규정합니다.
- 파인튜닝 데이터 동의: 발화 데이터를 학습에 쓰려면 별도 동의·보존 정책이 필요합니다. “교정에 쓰는 것”과 “학습에 쓰는 것”은 다른 동의 범위입니다.
17. 실전 안티패턴
| 안티패턴 | 왜 위험한가 | 대안 |
|---|---|---|
| rawText를 교정본으로 덮어쓰기 | 근거 소실, 롤백 불가 | rawText 불변, displayText 파생 |
| 짧은 약어 전역 문자열 치환 | 다른 문맥까지 손상 | 토큰 경계·문맥 조건·confidence |
| Partial까지 교정 적용 | 화면 요동, 다운스트림 오염 | FINAL 확정본에만 교정 |
| 프롬프트에 긴 용어집/오류문장 누적 | 프롬프트 오염·환각 위험 상승 | 짧은 공개 용어만, 상한 준수 |
| 한 사람 한 번 수정으로 전역 규칙 | 오타·악의가 파이프라인 오염 | N회·다수 확인 후 승격 |
| 규칙을 만든 세트에서 개선을 증명 | 평가 누수, 일반화 착시 | 개발·회귀 세트와 미사용 홀드아웃 세트 분리 |
| 첫 요구에 곧장 파인튜닝 | 비용·위험 과다, 근본원인 미해결 | 값싼 계층 소진 후 결정 |
| 다운스트림별 소스 버전 미정의 | 요약·검색·감사가 어긋남 | 소비자별 소스·버전 규칙 명시 |
18. 운영 전 점검 체크리스트
| 점검 영역 | 확인 질문 |
|---|---|
| 원본 보존 | rawText를 절대 덮어쓰지 않는가 |
| 계층 분리 | rawText / displayText / 요약이 분리·버전화돼 있는가 |
| 정규화 | 유니코드(NFC)·공백을 가장 먼저 정규화하는가 |
| 숫자/날짜 | 관용 수사를 깨지 않는 화이트리스트 규칙인가 |
| 치환 규칙 | 토큰 경계·문맥 조건·confidence·scope가 있는가 |
| 동음이의어 | 확신 없으면 교정하지 않고 후보로만 표시하는가 |
| 용어 사전 | 짧고 공개 가능한 용어만, 프롬프트 상한을 지키는가 |
| 프롬프트 오염 | Partial/오류문장/목표문장을 누적 주입하지 않는가 |
| 의사결정 | 파인튜닝 전에 값싼 계층을 모두 소진했는가 |
| 피드백 | 명시/암시 수집이 구분되고 개인정보가 분리되는가 |
| 규칙화 | N회·다수 확인 후 승격하고 소스 가중치를 두는가 |
| 오류 카탈로그 | 오류 클래스별로 처방·상태를 추적하는가 |
| Baseline | 개발·회귀 세트와 미사용 홀드아웃 세트를 분리했는가 |
| 평가 전처리 | 문장부호·대소문자·띄어쓰기·숫자 정규화 정책을 고정·기록했는가 |
| 지표 | 전체 WER 외에 오류 클래스별 지표와 교정 정밀도·오교정률·적용률·보류율을 보는가 |
| 다운스트림 | 소비자별 소스·버전을 명시했는가 |
| 거버넌스 | 규칙 소유권·비활성화·감사·만료가 있는가 |
| 개인정보 | 발화 텍스트를 로그·메트릭 Label에 넣지 않는가 |
| 삭제 전파 | 원본 삭제가 동의·법적 보존 정책에 따라 교정본·피드백·규칙 근거에 전파(삭제 또는 연결 해제)되는가 |
마무리
STT 정확도 개선은 “더 좋은 모델을 고르는 문제”가 아니라 오류를 계층으로 분해하고, 값싼 계층부터 추적 가능하게 처방하며, 사람의 교정을 시스템으로 되먹이는 운영 문제입니다.
핵심 원칙을 다시 정리합니다.
- rawText는 재처리 기준으로 불변 보존하고(최종 원천은 원본 오디오), displayText는 버전으로 파생·재생성한다.
- 인식 후 교정은 정규화 → 표기 통일 → 문맥 교정 순서로, 무엇을 왜 바꿨는지 남긴다.
- Hotwords·initial_prompt는 확률 힌트일 뿐 학습이 아니며, 신뢰성과 토큰 상한의 한계를 가진다.
- 파인튜닝은 흔히 값싼 계층을 먼저 검토한 뒤에 판단하는 것이 안전하다. 이때 성패는 데이터 양만으로 결정되지 않으며, 충분한 양과 함께 대표성·정합성·라벨 품질·평가 설계가 모두 중요하다.
- 사람 교정 피드백은 명시/암시를 구분하고, 개인정보를 분리하며, N회·다수 확인 후 규칙화한다.
- 개발·회귀 세트와 미사용 홀드아웃 세트를 분리하고, 오류 클래스별 지표·교정 전용 지표 없이는 개선을 주장할 수 없다.
- 규칙과 사전도 코드처럼 소유권·버전·감사·만료를 가진 거버넌스 대상이다.
실시간 자막의 품질 게이트가 companion 글의 주제였다면, 이 글은 그 위에서 시간이 지날수록 좋아지는 STT를 만드는 방법입니다. 좋은 STT 서비스는 처음부터 완벽한 모델이 아니라, 오류를 추적하고 사람의 교정을 축적해 스스로 도메인에 적응해 가는 파이프라인입니다.
참고 자료
- faster-whisper (SYSTRAN) GitHub
- faster-whisper: transcribe 옵션 정의 (hotwords, initial_prompt, beam_size, no_speech_threshold 등)
- OpenAI Whisper: Transcription logic
- OpenAI Cookbook: Whisper prompting guide (철자 유도·토큰 상한·신뢰성 한계)
- OpenAI Cookbook: Addressing transcription misspellings — prompt vs post-processing
- Hugging Face Blog: Fine-Tune Whisper For Multilingual ASR with 🤗 Transformers
- Unicode Normalization Forms (UAX #15)
- Python unicodedata 문서
- 실시간 STT 설계: 48kHz 리샘플링, Partial·Final 자막과 환각 억제 (companion 글)
- 회의 음성을 RAG로 연결하는 파이프라인 (원본·교정본 계층화, companion 글)
위 GitHub 소스 링크는
master/main브랜치를 가리키므로 시간이 지나면 내용이 달라질 수 있습니다. 본문의 인용은 2026년 7월 30일 기준 소스이며, 정확히 재현하려면 해당 시점의 commit permalink 또는 사용한 릴리스 태그로 고정해 확인하는 것을 권장합니다.
이 글은 2026년 7월 30일 기준 공개된 faster-whisper·OpenAI Whisper·OpenAI Cookbook·Hugging Face 문서와, 공개 가능한 실시간 STT·서버 녹화 상용 운영 및 상용화 검증 경험을 바탕으로 작성했습니다. 본문의 정확도·비용·지연은 특정 제품의 측정값이 아니라 일반적 방법론이며, 구체 수치(WER/CER, 임계값, 가중치)는 모델 버전·언어·도메인·오디오 환경에 따라 실제 데이터로 다시 측정·조정해야 합니다.
'프로젝트 문제 해결' 카테고리의 다른 글
| 화자 분리(Speaker Diarization) 설계: STT와는 다른 문제 (0) | 2026.07.30 |
|---|---|
| 비동기 AI 요약 완료 상태 설계: Polling·Event와 Readiness Predicate (0) | 2026.07.30 |
| STT는 성공했는데 AI 요약은 왜 실패할까: 비동기 파이프라인 장애 진단 (0) | 2026.07.30 |
| 실시간 STT 설계: 48kHz 리샘플링, Partial·Final 자막과 환각 억제 (0) | 2026.07.29 |
| 브라우저 WebRTC가 연결되지 않을 때: ICE, mDNS, NAT와 TURN 진단 순서 (0) | 2026.07.29 |