Back to Opendataloader Pdf

hancom-ai 페이지 분할 처리 계획

docs/superpowers/plans/2026-08-21-hancom-page-chunking.md

2.5.314.7 KB
Original Source

hancom-ai 페이지 분할 처리 계획

작성일 2026-08-21 대상 opendataloader-pdf-core / org.opendataloader.pdf.hybrid.HancomAIClient 배경 hancom-ai 서버는 한 요청에 30페이지까지만 처리 가능. 그보다 긴 PDF는 페이지를 분할 전송하고 결과를 병합해야 한다.


1. 리서치 결과 (코드 실측)

1.1 청킹은 이미 있는데, hancom-ai에는 걸려 있지 않다

HybridDocumentProcessor.BACKEND_CHUNK_SIZE = 50 으로 페이지를 잘라 convert()를 반복 호출하는 루프가 이미 존재한다(HybridDocumentProcessor.java:672). issue #352(대형 스캔 PDF hang) 대응으로 들어간 코드다.

그런데 HancomAIClientrequest.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페이지 한계에 걸리는 이유가 이것이다. 청킹을 "추가"하는 작업이 아니라, 이미 있는 청킹을 클라이언트에 실제로 연결하는 작업이다.

1.2 급소: DLA의 page_number는 요청 상대값이다

픽스처 실측(fixtures/DLA/01030000000001.json, 1페이지 PDF):

json
{"page_number": 0, "image_width": 1830, "image_height": 2775, "objects": [...]}

page_number그 요청 안에서의 0-based 순번이다. 반면 HancomAISchemaTransformer.java:458 은 이 값을 문서 절대 페이지 인덱스로 신뢰해 결과 리스트를 인덱싱한다:

java
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 필드를 갖는다.

1.3 분할이 필요한 호출은 하나뿐이다

호출현재 입력 단위분할 필요
callModule(DOCUMENT_LAYOUT_WITH_OCR)PDF 전체예 — 유일한 대상
/support/pdf2imgPAGE_INDEX 페이지 1장아니오 (이미 페이지 단위)
TSR / 캡션 / 수식크롭 이미지 1장아니오 (이미 영역 단위)

즉 30페이지 한계를 만드는 건 DLA+OCR 한 호출이고, 나머지 파이프라인은 이미 페이지·영역 단위라 손댈 필요가 없다. 작업 범위가 생각보다 좁다.

1.4 사용자 제안에 대한 판단

"pdf를 한장씩 쪼개서 이미지로 만들어서 hancom-ai에 전달"

이미지가 아니라 PDF 조각으로 쪼개는 쪽을 권한다. 이유:

  • DLA+OCR은 DATA_FORMAT: pdf로 호출되고, PDF의 텍스트 레이어를 활용한다. 페이지를 300DPI PNG로 만들어 보내면 디지털 PDF가 스캔 문서로 강등되어 텍스트가 OCR을 거치게 된다 — 정확도가 떨어지고 느려진다.
  • PDFBox로 페이지 부분집합 PDF를 만드는 건 이미 의존성 안에서 가능하다.
  • 이미지 경로는 이미 pdf2img가 담당하고 있고, 그건 크롭용이지 레이아웃 분석용이 아니다.

한 장씩(1페이지)이 아니라 **N페이지 묶음(기본 20~25)**을 권한다. 페이지당 1회 호출은 왕복 지연이 페이지 수만큼 선형으로 붙는다. 30이 한계라면 안전 여유를 둔 값이 낫다.

1.5 검증 공백 (중요)

scripts/mock_server/pdfs 200개 코퍼스는 전부 1페이지 문서다 (실측: >30p: 0). 지금까지의 모든 hybrid 검증 — 200개 회귀, 목 서버 픽스처, veraPDF 통과율 — 이 다중 페이지 경로를 한 번도 실행하지 않았다. 이 작업은 검증 자산을 새로 만들어야 한다.

로컬에 있는 다중 페이지 실물:

파일페이지용도
samples/pdf/pdfua-1-reference-suite-1-1/PDFUA-Ref-2-09_Scanned.pdf82경계 케이스, 스캔
odl-test-fixtures/inbox/352-hybrid-hang-large-scanned-pdf/1985.pdf154#352 회귀
odl-test-fixtures/inbox/367-backend-processing-error-large-pdf/…Gravebound…pdf255#367 회귀
.../PDFUA-Ref-2-01_Magazine-danish.pdf32경계 바로 위 (32 > 30)
.../umich-2026-04-01/input-full/Copy of 011-UM19-11equationsNR.pdf34수식 + 다중 페이지

1.6 미확인 사항

30페이지 한계를 실측 확인하지 못했다. 조사 시점에 실서버(172.16.198.219:18008)와 스테이징(semantix-odl.stg…) 모두 도달 불가였다(ping 000 / 503, GitHub API도 TLS 실패). 따라서 30은 사용자가 알려준 값을 그대로 전제한 것이고, 코드에서 교차 확인된 수치가 아니다. 아래 T0에서 가장 먼저 실측한다. 한계가 페이지 수가 아니라 파일 크기·픽셀 총량일 가능성도 있어서, 그 경우 분할 기준이 달라진다.


2. 설계

2.1 구조

HancomAIClient.convert() 안에서 DLA 호출만 분할하고, 클라이언트가 받은 pageNumbers를 존중하게 만든다.

구현 시 변경: 처리기(HybridDocumentProcessor)의 50페이지 루프를 "그대로 둔다"고 설계했으나, hancom-ai에서는 우회하도록 바꿨다(effectiveChunkSizeInteger.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 기준, 이미 페이지 단위)

2.2 재번호 매기기

slice가 정렬된 절대 0-based 페이지 리스트일 때, 응답의 page_number: kslice.get(k)가 절대 페이지다. 이 매핑을 DLA 페이지 레코드에 적용한다. object_id는 페이지 안에서만 유효하므로 건드리지 않는다.

주의: 서버가 페이지를 빠뜨리거나 순서를 바꿔 돌려줄 수 있으므로, k >= slice.size()면 그 레코드는 버리고 경고를 남긴다(조용한 오배치 금지).

2.3 원본 PDF 기준 유지

TSR/수식/캡션은 분할 PDF가 아니라 원본 pdfBytes와 절대 페이지 번호로 동작해야 한다. pdf2imgPAGE_INDEX로 원본을 렌더하기 때문이다. 재번호를 DLA 병합 직후에 끝내두면 이 단계는 코드 변경이 없다 — 이게 이 설계를 고른 이유다.

2.4 부분 실패 처리

한 슬라이스가 실패해도 나머지는 살린다. 현재는 DLA 결과가 비면 IOException을 던져 문서 전체가 Java 경로로 폴백한다. 분할 후에는:

  • 모든 슬라이스 실패 → 기존대로 IOException (문서 폴백)
  • 일부 슬라이스 실패 → 성공분만 병합하고, 실패 페이지를 failedPages로 올려 처리기가 그 페이지만 Java 경로로 재처리하게 한다 (response.hasFailedPages() 경로가 이미 있다)

250페이지 문서에서 한 슬라이스 실패로 전체를 버리는 건 손실이 너무 크다.

2.5 설정

HybridConfiglayoutPageChunk 추가, 기본 20. CLI 노출은 하지 않는다(내부 조정용). 0/음수면 분할 끔(단일 요청) — 실서버 한계가 다르게 밝혀질 때의 탈출구.

2.6 부수 수정: lastHybridRawJson 마지막 청크 승

HybridDocumentProcessor:206 주석대로 다중 청크에서 앞 청크 JSON이 버려진다. 지금까지 hancom-ai는 실질 단일 청크였으니 드러나지 않았지만, 이번 작업으로 진짜 다중 슬라이스가 생기면 evidence report가 앞부분 페이지 증거를 잃는다. 클라이언트 내부에서 병합해 하나의 JSON으로 돌려주므로 이 문제는 자연히 해소된다 — 단, 처리기의 50페이지 루프가 2회 이상 돌면 여전히 남는다. hancom-ai에서는 BACKEND_CHUNK_SIZE(50) > 클라이언트 분할(20)이라 처리기 루프가 여러 번 도는 조건이 그대로 있으므로, T5에서 명시적으로 확인한다.


3. 작업 분해 (TDD)

각 작업은 red → green → 리팩터. 완료조건과 검증조건을 분리해 적었다.

T0 — 서버 한계 실측 (선행, 코드 변경 없음)

  • 할 일: 서버 복구 후 82p/154p 문서로 페이지 수를 올려가며(10/20/25/30/32/40) DLA 호출. 실패 시 HTTP 코드·응답 본문·소요 시간 기록. 파일 크기/픽셀 총량과의 상관도 같이 본다.
  • 완료조건: 한계가 페이지 수인지 다른 축인지 판정, 실패 시 서버 반응 형태 확보.
  • 검증조건: 한계 직전/직후 값이 재현 가능하게 2회 일치.
  • 산출물: docs/hybrid/research/page-limit-probe.md
  • 차단 시: 서버가 계속 불가면 20을 기본값으로 진행하고, 이 문서에 "미실측" 명기.

T1 — 페이지 부분집합 PDF 추출

  • 테스트(red): 82p 샘플에서 [30..49] 추출 → 20페이지 PDF, 각 페이지의 미디어박스 크기가 원본 해당 페이지와 일치. 빈 리스트·범위 초과·단일 페이지 경계.
  • 완료조건: extractPages(byte[], List<Integer>) → byte[] 구현. PDFBox 사용, 스트림 누수 없음(try-with-resources).
  • 검증조건: 추출 PDF 페이지 수 == 슬라이스 크기, 원본 미변형(입력 배열 불변).

T2 — 상대→절대 재번호 매기기 ★급소

  • 테스트(red): page_number: 0,1,2 응답 + slice [30,31,32]30,31,32. 비연속 slice [5,9,40]도 정확히 매핑. k >= slice.size()인 레코드는 폐기+경고. TSR/수식/캡션 결과의 page_number도 동일 규칙.
  • 완료조건: 순수 함수로 분리(응답 트리 in-place 변형 금지 — CodeRabbit 지적 이력).
  • 검증조건: 재번호 후 어떤 두 레코드도 같은 절대 페이지를 갖지 않는다(충돌 검출 단정).

T3 — 분할 루프 배선

  • 테스트(red): MockWebServer로 45페이지 요청 → DLA 호출이 3회(20/20/5), 각 요청의 FILE 파트 페이지 수가 20/20/5, 병합 결과 페이지 수 45, 페이지 순 정렬. getPageNumbers()가 빈 경우 전체 페이지로 동작.
  • 완료조건: convert()request.getPageNumbers()를 존중. 30페이지 이하 문서는 기존과 동일하게 단일 호출(회귀 없음).
  • 검증조건: 1페이지 문서의 요청 바이트가 변경 전과 동일 — 200개 코퍼스 회귀 무영향.

T4 — 부분 실패 격리

  • 테스트(red): 3슬라이스 중 2번째만 HTTP 500 → 1·3번째 페이지는 결과에 있고, 2번째 슬라이스 페이지들은 failedPages에 오른다. 전 슬라이스 실패 → IOException.
  • 완료조건: 2.4 정책 구현.
  • 검증조건: 부분 실패 문서가 예외 없이 완주하고, 실패 페이지가 Java 경로로 재처리됨.

T5 — 처리기 루프와의 상호작용

  • 할 일: BACKEND_CHUNK_SIZE(50)와 클라이언트 분할(20)이 겹칠 때 lastHybridRawJson 손실이 실제로 나는지 60페이지 문서로 확인. 나면 누적으로 고치거나 hancom-ai에서 처리기 청킹을 우회한다(클라이언트가 이미 분할하므로 이중 분할은 무의미).
  • 완료조건: 60페이지 문서의 evidence report가 모든 페이지 증거를 담는다.
  • 검증조건: ai-raw/DLA/*.json의 페이지 수 == 문서 페이지 수.

T6 — 다중 페이지 검증 자산

  • 할 일: 1.5의 다중 페이지 문서로 소규모 회귀 셋 구성(32p 경계, 34p 수식, 82p 스캔). 목 서버 픽스처는 페이지 단위 룩업이므로 record_from_run.py 재사용 가능한지 확인.
  • 완료조건: smoke-mock.sh에 다중 페이지 케이스가 최소 1건 포함.
  • 검증조건: 32페이지 문서 전 페이지가 출력에 존재, 페이지 내용이 원본 페이지와 대응 (첫/중간/끝 페이지 텍스트 대조).

T7 — 실서버 종단 검증

  • 할 일: 82p·154p를 odl-pdfua 파이프라인으로 완주. veraPDF UA-1/UA-2.
  • 완료조건: 다중 페이지 문서가 태그 PDF까지 나온다.
  • 검증조건: 페이지 수 일치, 페이지 경계에서 구조 유실 없음, veraPDF 결과를 main과 대조 (신규 실패 0건).

T8 — 서브에이전트 코드리뷰

  • 정합성(재번호), 자원(대형 PDF 메모리), 동시성(sourcePdfShaShort 기존 이슈와의 상호작용).

4. 순서와 의존

T0 (실측) ─┐
T1 (추출) ─┼→ T3 (배선) → T4 (부분실패) → T5 (처리기) → T6 (자산) → T7 (실서버) → T8 (리뷰)
T2 (재번호)┘

T1·T2는 병렬 가능하고 서버 없이도 진행된다. T0은 T3의 기본값만 정하므로 서버가 막혀 있어도 T1·T2·T3을 20 기본값으로 먼저 만들 수 있다.


5. 위험

위험영향대응
재번호 오류로 페이지 뒤섞임조용히 잘못된 문서T2 충돌 단정 + T6 페이지 내용 대조
한계가 페이지 수가 아님(크기/픽셀)분할 기준이 틀림T0에서 먼저 판정
대형 PDF 슬라이스 메모리255p 문서 OOM슬라이스 순차 처리·즉시 해제, T8에서 점검
페이지당 왕복 지연 누적250p 문서 매우 느림묶음 20, 병렬화는 범위 외(서버 부하 미확인)
처리기 이중 분할중복 업로드 잔존T5에서 정리

6. 범위 외

  • DLA 슬라이스 병렬 전송 — 서버 동시성 한계 미확인. 순차로 먼저 정확성 확보.
  • /extension/du 마이그레이션(2단계) — 서버가 크롭 오케스트레이션까지 하면 이 설계가 달라질 수 있으나, 지금은 /hocr/sdk 유지 결정이 유효하다.
  • CIU(차트) 옵션화 — 별건.