8.5 LLM으로 메타데이터 정리하기 - 구조화 출력과 근거 검증
음성 인식 대본에서 제목·요약·키워드·인물을 JSON으로 뽑는 프롬프트와 스키마, 로컬 27B 모델 실행 결과(소요 시간 포함), 시각 형식 위반 8건을 잡아낸 검증 코드를 설명합니다.
#LLM이 하는 일과 하지 않아야 할 일
STT와 OCR이 만든 텍스트는 길고 정리되지 않은 원료입니다. 이것을 사람이 읽기 좋은 제목, 요약, 키워드, 등장 인물 목록으로 정리하는 일은 언어 모델이 잘하는 영역입니다. 반대로 원본에 없는 사실을 만들어 내는 것도 언어 모델의 특성이므로, 설계의 핵심은 "정리는 맡기고 사실은 검증한다"입니다.
원칙은 세 가지입니다.
- 입력에 있는 내용만 쓰게 하고, 없는 정보는
null로 두게 합니다. - 자유 서술이 아니라 정해진 JSON 구조로 받습니다.
- 결과를 저장하기 전에 코드로 검증합니다(모델의 자기 평가를 믿지 않습니다).
#실험 조건
- 입력: 직접 만든 가상 뉴스 대본 7구간(약 90초 분량). 인물과 행사는 가상이고 지명(광화문광장, 세종대로)만 실제 지명을 썼으며, 실제 방송 내용이 아닙니다.
- 모델: 로컬
qwen3.8:27b(Ollama, Q4_K_M),temperature=0,seed=1, 추론(thinking) 기능을 끄는 요청(think=false, 이를 지원하는 모델에서) - 출력 구조: JSON 스키마를
format에 지정해 스키마에 맞는 JSON만 생성하도록 제한
[00:00:03-00:00:09] 앵커: 오늘 오전 광화문광장에서 가을 축제가 막을 올렸습니다.
[00:00:09-00:00:18] 기자: 개막식에는 시민 3만여 명이 몰렸고, 행사는 오는 일요일까지 이어집니다.
[00:00:18-00:00:31] 기자: 올해는 지역 공방 마흔 곳이 참여하는 공예 장터와 야간 영상 상영회가 새로 마련됐습니다.
[00:00:31-00:00:46] 김하늘 위원장: 시민들이 직접 만든 영상 작품을 광장에서 함께 보는 자리가 되면 좋겠습니다.
[00:00:46-00:01:02] 기자: 다만 행사 기간 중 세종대로 일부 구간이 오후 6시부터 통제돼 우회가 필요합니다.
[00:01:02-00:01:15] 시민: 아이들과 같이 왔는데 체험 부스가 많아서 시간 가는 줄 몰랐어요.
[00:01:15-00:01:30] 기자: 내일은 오전부터 구름이 많고 오후 한때 비가 오겠습니다. 광화문에서 전해드렸습니다.
#프롬프트와 스키마
schema = {"type": "object", "properties": {
"title": {"type": "string"}, "summary": {"type": "string"},
"keywords": {"type": "array", "items": {"type": "string"}},
"people": {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}, "tc": {"type": "string"}}, "required": ["name", "tc"]}},
"organization": {"type": ["string", "null"]}, "visitor_count": {"type": ["string", "null"]},
"key_moments": {"type": "array", "items": {"type": "object", "properties": {"tc": {"type": "string"}, "note": {"type": "string"}}, "required": ["tc", "note"]}}},
"required": ["title", "summary", "keywords", "people", "organization", "visitor_count", "key_moments"]}
SYSTEM = ("당신은 방송 아카이브의 메타데이터 작성 보조입니다. 대본에 있는 내용만 사용하고, 대본에 없는 정보는 null로 두세요. "
"tc는 대본에 나온 구간의 시작 시각(HH:MM:SS)을 그대로 쓰세요.")
organization(주최 기관)은 대본에 없는 정보입니다. 일부러 넣은 함정으로, 모델이 지어내는지 보려는 목적입니다.visitor_count는 대본에 있는 수치를 문자열 그대로 옮기게 했습니다. 모델이 숫자를 변환하다 틀리는 일을 줄이기 위해서입니다.
#실행 결과
소요 시간은 30.1초였습니다(입력 391토큰, 출력 572토큰). 결과의 핵심입니다.
{
"title": "광화문광장 가을 축제 개막",
"summary": "광화문광장에서 가을 축제가 개막했으며, 시민 3만여 명이 참석했다. 올해는 공예 장터와 야간 영상 상영회가 신설되었고, 행사 기간 중 세종대로 일부 구간이 통제된다. 내일 날씨와 시민 인터뷰도 포함되었다.",
"keywords": ["광화문광장", "가을 축제", "공예 장터", "야간 영상 상영회", "세종대로 통제", "시민 인터뷰"],
"people": [{ "name": "김하늘", "tc": "00:00:31-00:00:46" }],
"organization": null,
"visitor_count": "3만여 명",
"key_moments": [
{ "tc": "00:00:03-00:00:09", "note": "광화문광장 가을 축제 개막" },
{ "tc": "00:00:46-00:01:02", "note": "세종대로 일부 구간 오후 6시부터 통제" }
]
}
(key_moments는 7건 중 2건만 옮겼습니다.)
잘한 점: 주최 기관은 대본에 없으므로 null로 두었고, 방문객 수는 대본의 표현 그대로 옮겼으며, 요약이 대본의 내용을 벗어나지 않았습니다.
지시를 어긴 점: tc를 "시작 시각만" 쓰라고 했는데 00:00:31-00:00:46처럼 구간으로 적었습니다. 사람이 보기에는 문제없지만, 시작 시각을 키로 DB에서 찾는 코드에서는 모두 실패합니다. 사실 내용이 맞아도 형식은 어긋날 수 있다는 예시입니다. people의 역할(위원장)도 구조에 필드가 없어 사라졌습니다.
#검증 코드
모델의 출력을 믿지 않고 대본과 대조합니다.
import re
TC = re.compile(r"^\d\d:\d\d:\d\d")
def normalize_tc(tc):
m = TC.match(tc.strip())
return m.group(0) if m else None
def validate(js, segs):
"""모델 출력이 대본에 근거하는지 확인한다. 반환: (고친 결과, 문제 목록)."""
starts = {a for a, _, _, _ in segs}
corpus = " ".join(f"{s} {t}" for _, _, s, t in segs)
problems = []
for key in ("key_moments", "people"):
for item in js[key]:
tc = normalize_tc(item["tc"])
if tc in starts:
item["tc"] = tc # "00:00:31-00:00:46" -> "00:00:31"
else:
problems.append((key, item["tc"], "시각이 대본의 시작 시각과 맞지 않음"))
for p in js["people"]:
if p["name"] not in corpus:
problems.append(("people", p["name"], "대본에 없는 이름"))
vc = js.get("visitor_count")
if vc and vc not in corpus:
problems.append(("visitor_count", vc, "대본에 없는 수치"))
return js, problems
검증 전 시각 불일치: 8 / 8 ← key_moments 7건 + people 1건이 모두 구간 형식
정규화 후 문제: [] ← 시작 시각으로 고치자 모두 대본의 시작 시각과 일치
정규화 후 시각: ['00:00:03', '00:00:09', '00:00:18', '00:00:31', '00:00:46', '00:01:02', '00:01:15']
일부러 대본에 없는 값을 주입해서 검증이 잡아내는지 확인했습니다.
people에 '박민수' 추가, visitor_count를 '5만 명'으로 변경
→ [('people', '박민수', '대본에 없는 이름'), ('visitor_count', '5만 명', '대본에 없는 수치')]
이 검증은 문자열이 대본에 있는지만 확인하므로 한계가 분명합니다. "김하늘"이 대본에 있어도 그 사람의 직책을 잘못 붙이는 오류는 잡지 못하고, 키워드(세종대로 통제)처럼 대본에 글자 그대로 있지 않은 요약성 표현은 검증 대상에서 뺐습니다. 검증할 수 있는 항목(고유명사, 수치, 시각)은 코드로 막고, 나머지는 평가와 검수(8장 6편)로 다룹니다.
#운영에 적용할 때 정할 것
| 항목 | 이 실험에서 본 것 | 설계 |
|---|---|---|
| 처리 시간 | 약 90초 분량에 30초 | 실시간보다 빠르지 않을 수 있음. 큐에 넣어 비동기로 처리하고 1시간 영상은 구간을 나눠 처리 |
| 긴 대본 | 입력이 모델의 컨텍스트 한도를 넘으면 처리 불가 | 시간 창으로 나눠 요약한 뒤 다시 요약하는 2단계 |
| 형식 위반 | 시작 시각을 구간으로 적음 | 정규화 + 재시도, 반복 실패는 검수 큐 |
| 환각 | 이 실험에서는 없음(주최 기관 null) | 고유명사·수치는 대본 대조, 모델 출력은 source = ai로 표시 |
| 재현성 | temperature=0, seed 고정 |
그래도 모델·버전이 바뀌면 결과가 바뀜 → 모델 이름·버전, 프롬프트를 결과와 함께 저장(9장 1편) |
#정리
- LLM에는 입력에 있는 내용만 쓰고 없으면
null이라는 규칙과 JSON 스키마를 줍니다. - 이 실험에서 모델은 사실 내용을 대본에 충실하게 정리했지만 시각 형식을 지시와 다르게 써서 8건 모두 어긋났습니다. 형식도 검증해야 합니다.
- 고유명사·수치·시각은 코드로 대본과 대조하고, 대조할 수 없는 부분은 평가와 사람 검수로 넘깁니다.
#참고 자료
링크는 2026-10-28에 열람이 되는 것을 확인했습니다. 규격과 도구 문서는 개정될 수 있으므로 적용하기 전에 최신 판을 확인하세요.