블로그

2026년 8월 15일 · 8분 읽기

온디바이스 LLM 긴 문서 요약: 청킹과 map/reduce로 컨텍스트 한계 넘기

컨텍스트가 좁은 온디바이스 LLM으로 긴 문서를 요약하려고 만든 청킹과 map/reduce 파이프라인입니다. 상한을 4,096으로 알고 조각 크기를 정했다가 다시 재게 된 경위를 적었습니다.

  • 온디바이스 LLM
  • Apple Foundation Models
  • 요약
  • 청킹

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쪽만 읽었습니다"라고 말해야 사용자가 요약의 범위를 압니다.