2026년 8월 15일 · 8분 읽기
온디바이스 LLM 긴 문서 요약: 청킹과 map/reduce로 컨텍스트 한계 넘기
컨텍스트가 좁은 온디바이스 LLM으로 긴 문서를 요약하려고 만든 청킹과 map/reduce 파이프라인입니다. 상한을 4,096으로 알고 조각 크기를 정했다가 다시 재게 된 경위를 적었습니다.
2026년 8월 23일 업데이트
요약 화면에 이런 문장이 떴습니다.
…이어서마침
표없이아주…음절 중간에서 잘린 조각입니다. 긴 메모를 자를 때 경계를 잘못 잡았습니다.
같은 시기에 다른 문제도 있었습니다. 10쪽 문서 하나가 19개 조각으로 갈렸습니다. 정리본에 같은 주제가 열아홉 개 섹션으로 늘어섰습니다.
한눈에 보기
| 문제 | 요약 화면에 이런 문장이 떴습니다. |
| 결정 | 1,200으로 정했습니다. |
| 결과 | 같은 시기에 요약 범위의 표시도 바꿨습니다. |
| 제약 | 조각 요약이 끝나면 제목이 필요합니다. |
한국어 메모는 빈 줄로 문단을 나누지 않습니다
첫 구현은 경계를 두 가지만 봤습니다. 빈 줄 두 개와 문장 부호입니다.
문장 부호는 ., !, ?, 。, !, ?였습니다. 영어와 일본어 문헌을 기준으로 고른 목록입니다.
실제 한국어 메모는 이렇게 생겼습니다.
회의 정리
다음주 화요일까지 시안 두 개
디자인 쪽에 미리 공유하기
예산은 아직 확정 아님빈 줄이 없습니다. 줄바꿈 하나로 항목을 나눕니다. 마침표도 없습니다.
경계를 못 찾으면 코드는 예산 위치에서 그냥 자릅니다. 그래서 …이어서마침이 나왔습니다. 한글은 한 음절이 여러 스칼라로 조합될 수 있어서, 스칼라 단위로 자르면 글자가 깨지기도 합니다.
경계를 다섯 등급으로 두었습니다
// TextChunker.swift:107-109
/// 경계 우선순위: 문단(빈 줄) > 줄바꿈 > 문장 끝 > 낱말(공백) > 강제.
/// 각 등급에서 하한 위쪽의 **가장 뒤** 경계를 쓴다 — 예산을 최대한 채워야
/// 모델 호출 횟수와 지연이 줄기 때문이다.
위에서부터 찾다가 걸리면 멈춥니다. 문단 경계가 있으면 거기서 자릅니다. 없으면 줄바꿈, 없으면 문장 끝, 없으면 공백입니다.
마지막 등급이 강제 절단입니다.
// TextChunker.swift:144-148
if paragraph > 0 { return paragraph }
if line > 0 { return line }
if sentence > 0 { return sentence }
if word > 0 { return word }
return upper // 공백조차 없는 연속 CJK — 이때만 스칼라 단위로 자른다.
공백이 하나도 없는 긴 한자·한글 덩어리에서만 여기까지 옵니다. 그 경우가 아니면 절대 음절 중간에서 자르지 않습니다.
각 등급에서 가장 뒤를 고릅니다
경계를 찾을 때 앞에서부터 찾지 않고 뒤에서부터 찾습니다.
예산이 1,200자인데 300자 지점에 문단 경계가 있고 1,100자 지점에도 있다면 뒤쪽을 씁니다. 앞쪽을 쓰면 조각이 300자짜리가 되고, 그만큼 조각 수가 늘어나고, 조각 수가 곧 모델 호출 횟수입니다.
// TextChunker.swift:125-142
var i = upper
while i > lower {
let prev = scalars[i - 1]
if prev == "\n" || prev == "\r" {
if line < 0 { line = i } // 처음 만난 것이 가장 뒤입니다
if paragraph < 0, hasBlankLineBreak(...) { paragraph = i }
} else if sentence < 0, isSentenceEnd(...) {
sentence = i
} else if word < 0, isWhitespace(prev) {
word = i
}
if paragraph >= 0 { break } // 최고 등급을 찾았으면 더 볼 필요가 없습니다
i -= 1
}
한 번만 거꾸로 훑으면서 등급별 최댓값을 채웁니다. 뒤에서 오므로 각 등급에서 처음 만나는 지점이 가장 뒤의 경계입니다.
너무 짧은 조각을 막는 하한이 필요했습니다
lower가 있는 이유가 실제 버그입니다.
// TextChunker.swift:67-70
/// 한 조각이 지나치게 짧아지는 걸 막는 하한 비율. 이게 없으면 문단 경계가
/// 맨 앞에 하나 있을 때 `"짧다."` 3글자가 독립 청크가 되어 모델 호출 한 번을
/// 통째로 낭비한다(구 코어의 실제 동작).
private static let minimumFillRatio = 2.0 / 5.0
예산의 40% 아래에서는 경계를 찾지 않습니다. 세 글자짜리 조각에 모델을 한 번 부르는 것은 낭비입니다. 온디바이스에서 호출 한 번은 초 단위입니다.
마지막 조각은 앞에 붙입니다
하한은 경계를 고르는 자리에서만 일합니다. 마지막 조각에는 듣지 않습니다. 청커가 남은 텍스트를 그대로 내놓기 때문입니다.
31자짜리 꼬리가 독립 조각이 됐습니다. 두 가지가 따라왔습니다. 모델 호출 하나를 통째로 먹고, 문장이 하나뿐이라 발췌 폴백도 거부되어 문서에 빈 섹션이 남았습니다.
// SummaryEngine.swift:424-433
static func mergingTail(_ chunks: [String], budget: Int) -> [String] {
guard chunks.count >= 2, let last = chunks.last else { return chunks }
let floor = budget * 2 / 5 // 예산의 40% 미만이면
guard last.unicodeScalars.count < floor else { return chunks }
let joined = merged[merged.count - 1] + " " + last
// 붙여서 예산을 크게 넘기면 그대로 둔다 — 컨텍스트를 넘기는 것이 더 나쁘다.
guard joined.unicodeScalars.count <= budget * 13 / 10 else { return chunks }
두 번째 guard가 이 함수의 안전장치입니다. 붙였을 때 예산의 130%를 넘으면 붙이지 않습니다. 빈 섹션 하나보다 컨텍스트를 넘기는 것이 나쁩니다.
같은 40%가 두 곳에서 다른 일을 합니다. 경계를 고를 때는 "여기서 자르지 마라"이고, 꼬리를 볼 때는 "이건 조각이 아니다"입니다.
고정값 1,000의 근거 두 개가 모두 낡았습니다
조각 하나의 크기를 1,000 스칼라로 박아 뒀습니다. 그 값을 고른 근거가 둘이었습니다.
첫째, 모델의 컨텍스트 상한이 4,096이라고 봤습니다. 둘째, 1,000 스칼라가 실제로 2,700에서 2,800 토큰쯤 된다고 봤습니다.
2026년 8월 13일에 iPhone 17 Pro와 iOS 27에서 문서 131건을 돌려 재 봤습니다. 둘 다 틀렸습니다.
| 근거로 삼았던 값 | 실측값 |
|---|---|
| 컨텍스트 상한 4,096 | iOS 26.4 이상에서 창은 8,192 |
| 1,000 스칼라 ≈ 2,750 토큰 | 한국어 0.627 토큰/스칼라, 즉 1,000 스칼라 ≈ 627 토큰 |
4,096은 창 크기를 물어볼 수 없을 때의 폴백값이었습니다. SystemLanguageModel.contextSize로 물어보면 8,192가 나옵니다.
두 번째가 더 큰 오차입니다. 2,750과 627은 네 배가 넘게 다릅니다. 옛 숫자는 프롬프트 템플릿의 오버헤드를 본문의 배율로 잘못 옮긴 값이었습니다. 지시문과 스키마가 차지하는 고정 비용을 본문 길이에 비례한다고 계산한 것입니다.
두 오차가 곱해졌습니다. 예산이 약 여덟 배 보수적이었습니다.
그 대가를 사용자가 봤습니다. 10쪽 문서가 19조각으로 갈려서 같은 주제가 열아홉 개 섹션으로 나왔습니다.
이제 창에서 계산합니다
// TextChunker.swift:39-44
public static func budget(forContextTokens contextTokens: Int) -> Int {
let usable = Double(contextTokens) * windowShare - Double(reservedTokens)
guard usable > 0 else { return minimumBudget }
let windowCeiling = Int(usable / worstCaseTokensPerScalar)
return max(minimumBudget, min(pageSizedBudget, windowCeiling))
}
세 값이 들어갑니다.
| 값 | 무엇 | 근거 |
|---|---|---|
windowShare 0.5 |
창의 절반만 원문에 씁니다 | 지시문이 길어지거나 토크나이저가 비싼 언어가 올 자리를 남깁니다 |
reservedTokens |
응답 상한 + 60 | 지시문 실측 59토큰에 여유를 얹었습니다 |
worstCaseTokensPerScalar 0.64 |
스칼라당 토큰의 최악값 | 한국어 산문 0.627, 표가 섞인 문서 0.637 |
최악값을 쓴 이유가 있습니다. 평균으로 계산하면 표가 많은 문서에서 창을 넘기고, 넘기면 요약이 실패합니다.
실측으로 대조했습니다. 예산 6,000일 때 최악의 프롬프트와 응답을 합쳐 4,119토큰. 창 8,192의 절반 언저리에서 걸렸습니다.
창이 허락하는 만큼 다 쓰면 요약이 사라집니다
계산식이 맞으니 예산을 창이 허락하는 최대치까지 올려 봤습니다. 5,868이 나왔습니다.
7쪽 PDF 하나를 예산만 바꿔 가며 네 번 돌렸습니다.
| 예산 | 조각 | 불릿 | 정리본 토큰 |
|---|---|---|---|
| 1,000 (첫 고정값) | — | — | 1,873 |
| 1,200 | 14 | 41 | 1,635 |
| 1,800 | — | — | 1,131 |
| 5,868 (창 최대) | 2 | 6 | 346 |
예산을 다섯 배 키우니 정리본이 1,635토큰에서 346토큰으로 줄었습니다. 14조각 41불릿이 2조각 6불릿이 됐습니다.
조각 하나가 원문 서너 쪽을 담으면 모델은 그 서너 쪽을 헤드라인 하나와 요점 셋으로 접습니다. points에 최대 세 개라는 상한이 있으니 조각이 커지는 만큼 버려지는 내용이 늘어납니다.
주석에 이렇게 적혀 있습니다.
창은 넘지 말아야 할 선이지 채워야 할 목표가 아니다.조각 수가 곧 분량입니다
세 값을 실물 문서로 재 보니 관계가 선명했습니다. 예산 5,868에서 346토큰, 1,800에서 1,131토큰, 1,000에서 1,873토큰입니다.
예산을 키우면 정리본이 짧아집니다. 조각 수가 줄고, 조각마다 요점 세 개라는 상한이 걸리기 때문입니다.
사용자는 옛값의 결과를 두고 "원래 빼곡했다"고 말했습니다. 그 밀도가 기준이 됐습니다.
// TextChunker.swift:46-52
/// 원문 한 쪽 남짓.
///
/// 값을 실물로 골랐다(2026-08-13, 7쪽·16쪽 PDF, 정리본 토큰 기준):
/// `5,868 → 346` · `1,800 → 1,131` · `1,000(옛값) → 1,873`. 조각 수가 곧 분량이다.
private static let pageSizedBudget = 1_200
1,200으로 정했습니다. 원문 한 쪽 남짓입니다. 정리본의 결이 문서의 쪽과 대체로 맞고, 화면이 "N페이지를 바탕으로 정리"라고 말하는 것과도 어긋나지 않습니다.
창에서 계산한 값은 그 위에 씌우는 상한으로만 남습니다. 창이 작은 기기에서는 이쪽이 먼저 걸립니다.
// TextChunker.swift:43
return max(minimumBudget, min(pageSizedBudget, windowCeiling))
// ^^^ 창이 허락해도 1,200을 넘지 않습니다
조각을 모델에 한 번씩 넣고 순서대로 조립합니다
조각을 만들었으면 각 조각에 모델을 한 번 부릅니다. 이것이 map입니다.
조각마다 독립 호출이므로 창에 들어가는 것은 항상 조각 하나입니다. 문서가 백 쪽이든 창 크기와 무관합니다. 늘어나는 것은 시간입니다.
| 입력 | 조각 수 | 모델 호출 |
|---|---|---|
| 짧은 메모 | 1 | 1 |
| 7쪽 PDF | 14 | 14 |
| 30쪽 문서 | 상한에 걸림 | 상한까지 |
호출 상한이 있습니다. 메모 프로필은 요청당 64회, 문서 프로필은 256회입니다. 상한을 넘기면 modelCallBudgetExceeded로 멈춥니다. 무한히 도는 것보다 멈추고 말하는 것이 낫습니다.
조각이 실패해도 자리를 남깁니다
조각 하나가 실패했을 때 그 조각을 결과에서 빼면 안 됩니다. 일곱 쪽 문서가 여섯 쪽처럼 보입니다.
그래서 실패한 조각도 자리를 지킵니다. 세 번 시도하고, 안 되면 원문에서 문장을 그대로 뽑아 채우고, 그것도 안 되면 요약하지 못했다고 표시된 빈 자리로 남깁니다.
| 조각의 상태 | 결과에 들어가는 것 |
|---|---|
| 모델이 요약함 | 헤드라인과 요점 |
| 세 번 실패 후 발췌 | 원문 문장 |
| 발췌도 실패 | 빈 자리와 표시 |
이 셋을 구분해서 저장하면 화면이 "이 부분은 요약하지 못했습니다"라고 말할 수 있습니다.
조각들을 다시 요약해 제목을 만듭니다
조각 요약이 끝나면 제목이 필요합니다. 조각들의 헤드라인을 모아 모델에게 한 번 더 넣습니다. 이것이 reduce입니다.
여기서는 조각용 스키마를 쓰지 않고 제목 전용 스키마를 씁니다. 조각 스키마를 재사용하면 모델이 요점도 함께 만들고, 제목만 필요한 자리에서 버릴 값을 만드는 셈입니다.
조각이 하나뿐이면 제목 호출을 건너뜁니다. 그 조각의 헤드라인이 곧 제목입니다.
용어를 바꾼 것도 수정이었습니다
이 파이프라인에서 사용자에게 보이는 단위를 "쪽"이라고 불렀습니다. 실제로는 글자 수로 자른 덩어리입니다.
10쪽 PDF가 14조각이 되면 화면에 "14쪽 중 3쪽 정리 중"이라고 떴습니다. 사용자가 보는 문서는 10쪽입니다. 숫자가 안 맞습니다.
그래서 "조각"으로 바꿨습니다. 코드의 변수 이름이 아니라 화면의 문구입니다.
같은 시기에 요약 범위의 표시도 바꿨습니다. 쪽 수 상한을 걷어내고 처리한 일의 양으로 말하게 했습니다. 상한이 30쪽인데 문서가 100쪽이면, "30쪽만 읽었습니다"라고 말해야 사용자가 요약의 범위를 압니다.