2026년 8월 15일 · 8분 읽기
LLM 요약 품질 검증: 원문 복사율과 반복 판정을 코드로 재기
LLM 요약이 쓸 만한지 사람 눈이 아니라 코드로 판정하는 기준을 세웠습니다. 원문을 거의 그대로 돌려준 요약이 품질 검사를 통과한 일에서 시작합니다.
2026년 8월 23일 업데이트
요약을 요청했는데 모델이 원문을 그대로 되돌려줬습니다. 그런데 우리 검사를 통과했습니다.
원인은 검사 순서였습니다. 모델의 답을 먼저 다듬고 길이를 자른 다음에 원문과 비교했습니다. 원문을 그대로 담은 답이 길이 제한에 걸려 잘려 나갔고, 잘린 덕에 "원문과 다르다"고 판정됐습니다.
검사를 통과시킨 것은 모델이 아니라 우리 코드였습니다.
한눈에 보기
| 문제 | 요약을 요청했는데 모델이 원문을 그대로 되돌려줬습니다. |
| 결정 | 고친 코드는 순서를 바꿨습니다. |
| 결과 | 두 종류의 실패에 다른 값을 매긴 것입니다. |
| 제약 | 이유는 비용입니다. |
다듬기 전에 원문과 견줍니다
고친 코드는 순서를 바꿨습니다.
// SummaryJudgment.swift:63-70
// **다듬기 전에 원문과 견준다.** 예전에는 sanitize·truncate를 거친 뒤 비교했고,
// 원문을 그대로 되돌려준 응답이 잘려 나간 덕에 그 문을 통과했다.
let asWritten = ([rawHeadline] + rawPoints).joined(separator: " ")
if copiesSource(asWritten, source: source),
!preservesShortSingleLine(headline: rawHeadline, points: rawPoints, source: source)
{
return .reject(.copiedSource)
}
rawHeadline과 rawPoints입니다. 모델이 준 그대로입니다. 다듬은 값이 아닙니다.
정규화는 검사를 무력화할 수 있습니다. 입력을 깔끔하게 만든 다음 검사하면, 더러웠던 사실이 사라진 뒤에 보는 셈입니다.
검사할 것이 원본의 성질이라면 원본을 봐야 합니다.
복사 판정은 0.9가 아니라 0.6이어야 했습니다
원문의 몇 퍼센트가 그대로 실렸을 때 복사로 볼지 정해야 합니다.
처음 생각은 0.9였습니다. 거의 전부 같으면 복사라고 보는 것입니다. 실제로는 더 낮춰야 했습니다.
// SummaryJudgment.swift:44-49
/// 원문의 이 비율 이상이 요약에 그대로 실렸으면 복사로 본다.
///
/// 0.9가 아니라 0.6인 이유: 앞부분만 요약하고 뒤에 원문을 이어 붙인 응답이 실제로
/// 관찰됐고(사용자 지적), 그 경우 복사율이 절반 남짓이었다. 정상 요약은 원문의
/// 20~40% 길이이므로 0.6에 닿지 않는다.
private static let copyCeiling = 0.6
모델이 이렇게 답했습니다. 앞부분은 성실하게 요약하고, 뒤에는 원문을 그대로 붙였습니다. 절반은 요약이고 절반은 복사입니다.
0.9로 두면 이 답이 통과합니다. 복사율이 절반 남짓이니까요.
0.6이 안전한 이유는 반대편에 있습니다. 정상 요약은 원문의 20에서 40% 길이입니다. 0.6에 닿지 않습니다. 사이에 여유가 있습니다.
| 응답의 성질 | 복사율 | 판정 |
|---|---|---|
| 정상 요약 | 20~40% | 통과 |
| 앞은 요약, 뒤는 원문 | 50% 남짓 | 거부 |
| 원문 그대로 | 100% | 거부 |
기준값을 고를 때 나쁜 쪽만 보면 안 됩니다. 좋은 쪽이 어디 있는지도 봐야 사이에 선을 그을 수 있습니다.
고유 문장으로 세지 않으면 정상 요약이 거부됩니다
복사율을 계산하는 방법에서 한 번 더 틀렸습니다.
처음에는 원문의 문장을 전부 세고, 그중 몇 개가 요약에 들어 있는지 봤습니다. 문제는 같은 문장이 여러 번 나오는 원문입니다.
// SummaryJudgment.swift:118-127
// **고유 문장으로 센다.** 같은 문장이 여러 번 나오는 원문(쪽마다 반복되는 머리글,
// 시세 알림처럼 형식이 같은 줄)에서 중복을 각각 세면, 요약이 그 문장 하나를 담기만
// 해도 복사율이 100%로 부풀려진다 — 정상 요약이 복사로 거부됐다.
var unique: [String] = []
var seen = Set<String>()
for sentence in sentences(in: source) {
let key = compact(sentence)
guard key.count >= 12, seen.insert(key).inserted else { continue }
unique.append(key)
}
PDF에서 쪽마다 반복되는 머리글을 생각하면 됩니다. 서른 쪽 문서에 같은 줄이 서른 번 나옵니다. 요약이 그 줄을 한 번 담으면, 중복을 각각 세는 계산에서는 서른 개를 다 맞춘 것이 됩니다.
시세 알림처럼 형식이 같은 줄도 같습니다.
key.count >= 12 조건도 있습니다. 열두 글자보다 짧은 문장은 세지 않습니다. "네", "확인" 같은 짧은 조각이 우연히 겹치는 것을 복사로 보면 안 됩니다.
문장이 하나뿐인 원문은 다르게 봅니다
고유 문장이 둘 미만이면 비율 계산이 의미가 없습니다.
// SummaryJudgment.swift:128-132
guard unique.count >= 2 else {
// 문장이 하나뿐인 짧은 원문은 길이로만 판정한다.
return Double(compactSummary.count) >= Double(compactSource.count) * copyCeiling
&& compactSummary.contains(compactSource)
}
길이 비율과 포함 관계를 함께 봅니다. 둘 다 만족해야 복사입니다.
짧은 한 줄에는 예외를 둡니다
"우유 사기"처럼 한 줄짜리 메모는 압축할 여백이 없습니다. 요약이 원문과 비슷해지는 것이 정상입니다.
예외를 여는 조건이 여섯 개입니다. 전부 만족해야 열립니다.
| 조건 | 값 |
|---|---|
| 원문 길이 | 24자에서 240자 사이 |
| 줄바꿈 | 없어야 합니다 |
| 요점 개수 | 1개에서 4개 |
| 헤드라인 | 비어 있지 않고 원문과 달라야 합니다 |
| 헤드라인 길이 | 원문보다 짧아야 합니다 |
| 요점을 이어 붙인 값 | 원문과 정확히 같아야 합니다 |
마지막 줄이 이 예외의 뜻입니다. 모델이 원문을 요점으로 정확히 보존하면서 별도의 짧은 제목을 따로 만들었을 때만 통과시킵니다. 원문을 그대로 두고 제목까지 원문으로 쓴 답은 여기서 걸립니다.
여러 줄이면 예외가 아닙니다. 표나 OCR 파편은 줄 경계에서 걸러집니다. 240자를 넘는 장문의 통째 복사는 여전히 실패합니다.
제목과 첫 요점이 같으면 접습니다
모델이 헤드라인을 만들고 그 문장을 첫 요점으로 한 번 더 씁니다. 조각 여러 개를 합치는 경로에서 이것이 걸렸습니다. DEF-047로 기록했고 심각도는 P2입니다. 조각이 하나인 경로는 코어가 이미 접고 있어서 범위를 합치는 경로로 좁혔습니다.
// SummaryJudgment.swift:74-78
// 모델이 헤드라인을 첫 요점으로 그대로 되풀이하는 일이 잦다(실측 DEF-047).
// 화면에서는 제목과 첫 줄이 같은 문장으로 겹쳐 보인다 — 요점 쪽을 접는다.
var points = dedupe(
rawPoints.map(tidy).filter { !$0.isEmpty }.flatMap(readablePoints)
).filter { compact($0) != compact(headline) }
거부하지 않고 접습니다. 나머지 요점은 쓸 만하니 답 전체를 버릴 이유가 없습니다.
compact()로 비교하는 것이 요점입니다. 공백과 문장 부호를 없앤 뒤 비교하므로 "회의 일정 확정"과 "회의 일정 확정."이 같은 것으로 잡힙니다.
마지막 요점이 끊겼으면 버립니다
토큰 예산 끝에서 마지막 값이 잘립니다. guided generation이 문법적으로는 닫아 주지만 내용이 중간에서 끊깁니다.
// SummaryJudgment.swift:80-83
if let last = points.last, !isCompletePoint(last) {
points.removeLast()
}
guard !points.isEmpty else { return .reject(.incomplete) }
마지막 하나만 확인합니다. 잘리는 것은 항상 마지막이기 때문입니다.
버린 뒤 요점이 하나도 남지 않으면 그때 거부합니다. 요점 하나가 있었고 그것이 잘린 경우입니다.
키 집합이 정확히 같아야 합니다
// SummaryJudgment.swift:58
guard Set(object.keys) == ["headline", "points"] else { return .reject(.wrongKeys) }
contains가 아니라 ==입니다. 필요한 키가 있는지만 보지 않고, 여분 키가 없는지도 봅니다.
모델이 {"headline": ..., "points": ..., "confidence": 0.95}를 주면 거부합니다. 요청하지 않은 필드를 만들었다는 것은 스키마를 벗어났다는 뜻이고, 그 답의 다른 부분도 믿을 수 없습니다.
거부 사유를 열한 가지로 나눕니다
판정 결과를 통과와 실패로만 나누면 프롬프트를 고칠 수 없습니다. 왜 실패했는지 알아야 합니다.
// SummaryJudgment.swift:9-32
enum Reason: String, Sendable, Equatable {
case notJSON // JSON이 아니다(코드펜스, 산문, 잘린 응답)
case wrongKeys // 키가 다르다(여분 키, 누락 키)
case empty // 헤드라인이나 요점이 비었다
case incomplete // 마지막 요점만 완결되지 않아 안전하게 버렸다
case overlong // 헤드라인이 화면 제목으로 읽기에 너무 길다
case copiedSource // 원문이 그대로 실렸다
case repeated // 같은 글자·구문의 되풀이
case wrongLanguage // 대상 언어와 다른 필드를 발견했다
case languageIndeterminate // 언어를 확정할 수 없어 승격하지 않았다
case translationFailed // 번역 또는 번역 후 검증이 실패했다
case modelFailed // 모델 호출 자체가 실패했다
}
각 사유가 다른 대응을 부릅니다.
| 사유 | 무엇을 고쳐야 하는가 |
|---|---|
notJSON |
스키마 강제가 작동하는지 |
wrongKeys |
스키마 정의 |
copiedSource |
지시문의 압축 요구 |
overlong |
헤드라인 길이 안내 |
repeated |
온도 설정이나 예산 |
wrongLanguage |
지시문의 언어 지정 |
이 사유들이 조각마다 쌓입니다. 성공한 조각에도 남습니다. 첫 시도에 성공한 것과 세 번째에 성공한 것을 구분하려면 그 기록이 필요합니다.
반복 판정은 두 가지를 봅니다
한 글자가 네 번 이상 이어지는 줄과, 한 줄 안에서 1~4낱말 구문이 세 번 이상 되풀이되는 것입니다. 1111111111이 앞이고 "승인 완료. 승인 완료. 승인 완료."가 뒤입니다.
| 규칙 | 걸리는 조건 | 잡는 것 |
|---|---|---|
| 같은 글자 | 한 줄이 같은 글자 4개 이상으로만 이루어짐 | 1111111111 |
| 같은 구문 | 1~4낱말 구문이 한 줄에서 3번 이상 | 승인 완료. 승인 완료. 승인 완료. |
두 규칙 다 줄 단위입니다. 여러 줄에 걸친 되풀이는 잡지 않습니다. 모델이 같은 문장을 요점 세 개에 나눠 넣으면 이 판정을 지나가고, 그것은 앞에서 본 중복 제거가 맡습니다.
낱말 비교는 소문자로 바꾸고 영숫자가 아닌 문자를 경계로 잘라서 합니다. 그래서 문장 부호가 사이에 끼어 있어도 같은 구문으로 잡힙니다.
문장 경계는 다섯 글자로만 나눕니다
복사율을 세려면 원문을 문장으로 나눠야 합니다. 그 기준은 ., !, ?, \n, 。 다섯입니다.
청킹 쪽에서 이미 겪은 문제가 여기에도 있습니다. 마침표 없이 줄바꿈으로 쓰는 한국어 메모는 줄바꿈이 경계 노릇을 합니다. 줄바꿈도 마침표도 없이 길게 쓴 메모는 통째로 한 문장이 되고, 그때는 고유 문장이 하나라서 비율 계산 대신 길이와 포함 관계로 판정하는 쪽으로 떨어집니다.
제목은 못 만들어도 실패가 아닙니다
조각들을 아우르는 제목을 만드는 판정은 조금 다릅니다.
// SummaryJudgment.swift:94
if object.isEmpty { return .accept(nil) }
빈 객체를 받으면 통과입니다. nil을 돌려줍니다.
제목은 있으면 좋고 없어도 되는 값입니다. 조각이 하나뿐이면 그 조각의 헤드라인이 제목이 됩니다. 모델이 제목을 못 만들었다고 요약 전체를 버릴 이유가 없습니다.
판정과 언어 검사를 나눴습니다
파일 맨 위 주석에 경계가 적혀 있습니다.
// SummaryJudgment.swift:5-6
/// 출력 언어는 여기서 모델 재시도의 이유로 쓰지 않는다. `SummaryLanguageAudit`가 각
/// 필드를 판정하고, 불일치만 번역한 뒤 다시 증명한다.
언어가 틀렸을 때 모델을 다시 부르지 않습니다. 번역합니다.
이유는 비용입니다. 모델을 다시 부르면 요약을 처음부터 만듭니다. 번역은 이미 만든 문장을 옮기는 것이므로 훨씬 쌉니다.
그리고 번역한 뒤에 다시 확인합니다. 번역기가 목표 언어로 옮겼는지 증명한 다음에만 결과로 씁니다.
| 문제 | 대응 |
|---|---|
| 규격·복사·반복 | 모델을 다시 부릅니다 |
| 언어 불일치 | 번역하고 다시 확인합니다 |
두 종류의 실패에 다른 값을 매긴 것입니다.