docs/superpowers/plans/2026-08-21-hancom-page-chunking.md
작성일 2026-08-21
대상 opendataloader-pdf-core / org.opendataloader.pdf.hybrid.HancomAIClient
배경 hancom-ai 서버는 한 요청에 30페이지까지만 처리 가능. 그보다 긴 PDF는 페이지를
분할 전송하고 결과를 병합해야 한다.
HybridDocumentProcessor.BACKEND_CHUNK_SIZE = 50 으로 페이지를 잘라 convert()를
반복 호출하는 루프가 이미 존재한다(HybridDocumentProcessor.java:672).
issue #352(대형 스캔 PDF hang) 대응으로 들어간 코드다.
그런데 HancomAIClient는 request.getPageNumbers()를 한 번도 읽지 않는다.
$ grep -n "getPageNumbers" hybrid/*.java
DoclingFastServerClient.java:173,174,175 ← page_range 로 변환해 전송
HancomAIClient.java ← (없음)
callModule()은 FILE 파트에 PDF 전체를 넣어 보낸다. 페이지 선택 파라미터가 없다.
결론: hancom-ai 경로에서 현재 청킹은 완전한 no-op이다. 100페이지 문서는 "1–50 전송 → 전체 100p 결과 수신 → 51–100 전송 → 전체 100p 결과 재수신"이 되어, 서버 입장에선 매번 100페이지를 처리하고 클라이언트는 같은 일을 두 번 시킨다. 30페이지 한계에 걸리는 이유가 이것이다. 청킹을 "추가"하는 작업이 아니라, 이미 있는 청킹을 클라이언트에 실제로 연결하는 작업이다.
page_number는 요청 상대값이다픽스처 실측(fixtures/DLA/01030000000001.json, 1페이지 PDF):
{"page_number": 0, "image_width": 1830, "image_height": 2775, "objects": [...]}
page_number는 그 요청 안에서의 0-based 순번이다. 반면
HancomAISchemaTransformer.java:458 은 이 값을 문서 절대 페이지 인덱스로 신뢰해
결과 리스트를 인덱싱한다:
int pageNumber = page.has("page_number") ? page.get("page_number").asInt() : i;
if (pageNumber >= result.size()) { while (result.size() <= pageNumber) result.add(...); }
따라서 31–60페이지를 잘라 보내면 응답은 page_number: 0..29로 돌아오고, 그대로
변환하면 31페이지 내용이 1페이지에 덮어써진다. 조용히 잘못된 문서가 나오는
유형의 실패라서, 이 재번호 매기기가 이 작업 전체의 정합성 급소다.
이 문제는 TSR/캡션/수식 결과에도 그대로 있다 — 각 항목이 page_number 필드를 갖는다.
| 호출 | 현재 입력 단위 | 분할 필요 |
|---|---|---|
callModule(DOCUMENT_LAYOUT_WITH_OCR) | PDF 전체 | 예 — 유일한 대상 |
/support/pdf2img | PAGE_INDEX 페이지 1장 | 아니오 (이미 페이지 단위) |
| TSR / 캡션 / 수식 | 크롭 이미지 1장 | 아니오 (이미 영역 단위) |
즉 30페이지 한계를 만드는 건 DLA+OCR 한 호출이고, 나머지 파이프라인은 이미 페이지·영역 단위라 손댈 필요가 없다. 작업 범위가 생각보다 좁다.
"pdf를 한장씩 쪼개서 이미지로 만들어서 hancom-ai에 전달"
이미지가 아니라 PDF 조각으로 쪼개는 쪽을 권한다. 이유:
DATA_FORMAT: pdf로 호출되고, PDF의 텍스트 레이어를 활용한다.
페이지를 300DPI PNG로 만들어 보내면 디지털 PDF가 스캔 문서로 강등되어
텍스트가 OCR을 거치게 된다 — 정확도가 떨어지고 느려진다.pdf2img가 담당하고 있고, 그건 크롭용이지 레이아웃 분석용이 아니다.한 장씩(1페이지)이 아니라 **N페이지 묶음(기본 20~25)**을 권한다. 페이지당 1회 호출은 왕복 지연이 페이지 수만큼 선형으로 붙는다. 30이 한계라면 안전 여유를 둔 값이 낫다.
scripts/mock_server/pdfs 200개 코퍼스는 전부 1페이지 문서다 (실측: >30p: 0).
지금까지의 모든 hybrid 검증 — 200개 회귀, 목 서버 픽스처, veraPDF 통과율 — 이
다중 페이지 경로를 한 번도 실행하지 않았다. 이 작업은 검증 자산을 새로 만들어야 한다.
로컬에 있는 다중 페이지 실물:
| 파일 | 페이지 | 용도 |
|---|---|---|
samples/pdf/pdfua-1-reference-suite-1-1/PDFUA-Ref-2-09_Scanned.pdf | 82 | 경계 케이스, 스캔 |
odl-test-fixtures/inbox/352-hybrid-hang-large-scanned-pdf/1985.pdf | 154 | #352 회귀 |
odl-test-fixtures/inbox/367-backend-processing-error-large-pdf/…Gravebound…pdf | 255 | #367 회귀 |
.../PDFUA-Ref-2-01_Magazine-danish.pdf | 32 | 경계 바로 위 (32 > 30) |
.../umich-2026-04-01/input-full/Copy of 011-UM19-11equationsNR.pdf | 34 | 수식 + 다중 페이지 |
30페이지 한계를 실측 확인하지 못했다. 조사 시점에 실서버(172.16.198.219:18008)와
스테이징(semantix-odl.stg…) 모두 도달 불가였다(ping 000 / 503, GitHub API도 TLS 실패).
따라서 30은 사용자가 알려준 값을 그대로 전제한 것이고, 코드에서 교차 확인된 수치가 아니다.
아래 T0에서 가장 먼저 실측한다. 한계가 페이지 수가 아니라 파일 크기·픽셀 총량일
가능성도 있어서, 그 경우 분할 기준이 달라진다.
HancomAIClient.convert() 안에서 DLA 호출만 분할하고, 클라이언트가 받은
pageNumbers를 존중하게 만든다.
구현 시 변경: 처리기(HybridDocumentProcessor)의 50페이지 루프를 "그대로 둔다"고
설계했으나, hancom-ai에서는 우회하도록 바꿨다(effectiveChunkSize →
Integer.MAX_VALUE). 클라이언트가 이미 분할하므로 바깥 분할은 파일 재업로드와
크롭 재실행만 늘리고, lastHybridRawJson이 마지막 호출만 남기는 탓에
앞 페이지 증거를 잃기 때문이다(2.6 참조).
convert(request)
├─ pages = request.getPageNumbers() (비면 전체 페이지)
├─ slices = split(pages, DLA_PAGE_CHUNK) // 기본 20
├─ for each slice:
│ subPdf = extractPages(pdfBytes, slice) // PDFBox
│ resp = callModule(subPdf, DLA_WITH_OCR)
│ renumber(resp, slice) // ★ 상대→절대 (급소)
│ accumulate
├─ merged DLA (절대 page_number, 페이지 순 정렬)
└─ 이후 TSR/수식/캡션은 기존 그대로 (원본 pdfBytes 기준, 이미 페이지 단위)
slice가 정렬된 절대 0-based 페이지 리스트일 때, 응답의 page_number: k는
slice.get(k)가 절대 페이지다. 이 매핑을 DLA 페이지 레코드에 적용한다.
object_id는 페이지 안에서만 유효하므로 건드리지 않는다.
주의: 서버가 페이지를 빠뜨리거나 순서를 바꿔 돌려줄 수 있으므로,
k >= slice.size()면 그 레코드는 버리고 경고를 남긴다(조용한 오배치 금지).
TSR/수식/캡션은 분할 PDF가 아니라 원본 pdfBytes와 절대 페이지 번호로 동작해야
한다. pdf2img가 PAGE_INDEX로 원본을 렌더하기 때문이다. 재번호를 DLA 병합 직후에
끝내두면 이 단계는 코드 변경이 없다 — 이게 이 설계를 고른 이유다.
한 슬라이스가 실패해도 나머지는 살린다. 현재는 DLA 결과가 비면 IOException을 던져
문서 전체가 Java 경로로 폴백한다. 분할 후에는:
IOException (문서 폴백)failedPages로 올려
처리기가 그 페이지만 Java 경로로 재처리하게 한다 (response.hasFailedPages() 경로가
이미 있다)250페이지 문서에서 한 슬라이스 실패로 전체를 버리는 건 손실이 너무 크다.
HybridConfig에 layoutPageChunk 추가, 기본 20. CLI 노출은 하지 않는다(내부 조정용).
0/음수면 분할 끔(단일 요청) — 실서버 한계가 다르게 밝혀질 때의 탈출구.
lastHybridRawJson 마지막 청크 승HybridDocumentProcessor:206 주석대로 다중 청크에서 앞 청크 JSON이 버려진다.
지금까지 hancom-ai는 실질 단일 청크였으니 드러나지 않았지만, 이번 작업으로 진짜
다중 슬라이스가 생기면 evidence report가 앞부분 페이지 증거를 잃는다.
클라이언트 내부에서 병합해 하나의 JSON으로 돌려주므로 이 문제는 자연히 해소된다 —
단, 처리기의 50페이지 루프가 2회 이상 돌면 여전히 남는다. hancom-ai에서는
BACKEND_CHUNK_SIZE(50) > 클라이언트 분할(20)이라 처리기 루프가 여러 번 도는 조건이
그대로 있으므로, T5에서 명시적으로 확인한다.
각 작업은 red → green → 리팩터. 완료조건과 검증조건을 분리해 적었다.
docs/hybrid/research/page-limit-probe.md[30..49] 추출 → 20페이지 PDF, 각 페이지의
미디어박스 크기가 원본 해당 페이지와 일치. 빈 리스트·범위 초과·단일 페이지 경계.extractPages(byte[], List<Integer>) → byte[] 구현. PDFBox 사용,
스트림 누수 없음(try-with-resources).page_number: 0,1,2 응답 + slice [30,31,32] → 30,31,32.
비연속 slice [5,9,40]도 정확히 매핑. k >= slice.size()인 레코드는 폐기+경고.
TSR/수식/캡션 결과의 page_number도 동일 규칙.getPageNumbers()가 빈 경우 전체 페이지로 동작.convert()가 request.getPageNumbers()를 존중. 30페이지 이하 문서는
기존과 동일하게 단일 호출(회귀 없음).failedPages에 오른다. 전 슬라이스 실패 → IOException.BACKEND_CHUNK_SIZE(50)와 클라이언트 분할(20)이 겹칠 때 lastHybridRawJson
손실이 실제로 나는지 60페이지 문서로 확인. 나면 누적으로 고치거나
hancom-ai에서 처리기 청킹을 우회한다(클라이언트가 이미 분할하므로 이중 분할은 무의미).ai-raw/DLA/*.json의 페이지 수 == 문서 페이지 수.record_from_run.py 재사용 가능한지 확인.smoke-mock.sh에 다중 페이지 케이스가 최소 1건 포함.odl-pdfua 파이프라인으로 완주. veraPDF UA-1/UA-2.main과 대조
(신규 실패 0건).sourcePdfShaShort 기존 이슈와의 상호작용).T0 (실측) ─┐
T1 (추출) ─┼→ T3 (배선) → T4 (부분실패) → T5 (처리기) → T6 (자산) → T7 (실서버) → T8 (리뷰)
T2 (재번호)┘
T1·T2는 병렬 가능하고 서버 없이도 진행된다. T0은 T3의 기본값만 정하므로 서버가 막혀 있어도 T1·T2·T3을 20 기본값으로 먼저 만들 수 있다.
| 위험 | 영향 | 대응 |
|---|---|---|
| 재번호 오류로 페이지 뒤섞임 | 조용히 잘못된 문서 | T2 충돌 단정 + T6 페이지 내용 대조 |
| 한계가 페이지 수가 아님(크기/픽셀) | 분할 기준이 틀림 | T0에서 먼저 판정 |
| 대형 PDF 슬라이스 메모리 | 255p 문서 OOM | 슬라이스 순차 처리·즉시 해제, T8에서 점검 |
| 페이지당 왕복 지연 누적 | 250p 문서 매우 느림 | 묶음 20, 병렬화는 범위 외(서버 부하 미확인) |
| 처리기 이중 분할 | 중복 업로드 잔존 | T5에서 정리 |
/extension/du 마이그레이션(2단계) — 서버가 크롭 오케스트레이션까지 하면 이 설계가
달라질 수 있으나, 지금은 /hocr/sdk 유지 결정이 유효하다.