티스토리 뷰

ePapyrus는 법률, 금융, 의료, 공공, 물류 등 다양한 산업 군에서 연간 수백만 건의 문서를 처리하는 고객들과 함께 일하고 있습니다. 그런데 여러분, 이 정도 대규모 스케일의 조직에서 모든 페이지를 아무 생각 없이 OCR 엔진이나 LLM(대형 언어 모델)으로 그냥 밀어 넣으면 어떻게 될까요? 당연히 시간과 비용 면에서 비효율적일 수밖에 없습니다.

그렇기 때문에 문서를 먼저 영리하게 분류하고 걸러내는 '사전 필터링' 작업이 반드시 필요합니다.

  • "이 페이지는 스캔한 이미지일까, 아니면 디지털 텍스트일까?"
  • "단순 서식 양식일까, 표가 빽빽한 보고서일까, 아니면 그냥 평범한 줄글일까?"
  • "애초에 비용을 써가며 처리할 만한 가치가 있는 페이지인가?"
  • "이 페이지에서 콘텐츠를 가장 효율적으로 뽑아내는 방법은 뭘까?"

 

이때 파이프라인의 맨 앞단에서 구원투수로 등판하는 게 바로 PyMuPDF입니다.

PyMuPDF는 PDF의 구조에 직접 접근하기 때문에, 나중에 연동될 무거운 도구들에 비해 단돈 몇 원으로, 몇 밀리초(ms) 만에 놀라울 정도로 풍부한 힌트(시그널)들을 찾아냅니다. 글자 수, 이미지 영역 비율, 블록 레이아웃 구조, 주석 타입, 벡터 그래픽 유무 등을 순식간에 수집해 주죠. 즉, API 토큰을 단 하나도 쓰기 전에 "이 페이지는 어디로 라우팅하는 게 가장 가성비가 좋을지" 확실한 근거를 마련할 수 있습니다.

그렇다면 이걸 실무에 어떻게 적용할 수 있을까요? PyMuPDF를 활용해 문서 분류(Triage) 레이어를 구축하는 한 가지 방법을 소개해드리려고 하는데요. 기본 뼈대로 쓰기에 충분히 범용적이면서도, 여러분이 다루는 문서 특성에 맞춰 얼마든지 확장할 수 있도록 설계되었습니다.

 

페이지 기반 분석 & 휴리스틱(Heuristics) 알아보기

문서 내부 엘리먼트들을 읽다 보면 해당 문서에 대한 감이 오기 마련인데요. 다행히 PyMuPDF를 쓰면 이런 정보들을 아주 쉽게 확보할 수 있습니다. 여기에 몇 가지 경험적 규칙(휴리스틱)을 얹어주면 각 페이지를 기가 막히게 분류할 수 있죠.

픽셀 디코딩(Pixel Decoding)이나 외부 API 호출 없이, 오직 PyMuPDF만 호출해서 다음과 같은 정보들을 빛의 속도로 뽑아낼 수 있습니다.

  • get_text("words"): 글자 및 단어 수 체크
  • get_image_info(): 픽셀을 직접 추출하지 않고 이미지 바운딩 박스 정보만 확인
  • find_tables(): 구조화된 테이블(표) 감지
  • page.annots(): 위젯이나 입력 양식(Form) 감지
  • get_drawings(): 벡터 그래픽 콘텐츠 유무 파악

 

우리는 이 정보들을 조합해서 각 페이지가 텍스트 중심인지, 이미지 중심(OCR 필수)인지, 혹은 레이아웃이 너무 복잡해서 더 고도화된 레이아웃 추출 솔루션이 필요한 페이지인지 판단하게 됩니다. PyMuPDF의 독보적인 속도분에 수많은 페이지를 순식간에 파싱하고 분류 기준을 세울 수 있다는 게 핵심입니다.

 

4가지 버킷으로 스마트하게 분류하기 (Classify)

자, 그럼 문서를 분류하기 위해 먼저 규칙이 적용된 '버킷(Bucket)'을 설정해 봅시다. 그 후 파이썬 스크립트를 돌려 각 페이지가 어떤 버킷에 쏙 들어가는지 매칭해 볼 것입니다.

우선 아래 표처럼 4가지 버킷과 분류 규칙을 정의해 보겠습니다.

분류 기준 및 규칙 테이블

버킷 / 태그 분류 규칙
SKIP 페이지 내 글자 수가 20자 미만이고, 이미지 영역 비율도 2% 미만일 때
OCR_NEEDED 이미지가 페이지의 25% 이상을 차지하는데, 디지털 글자 수는 30자 미만일 때
LLM_NEEDED 아래의 레이아웃 복잡도 체크리스트*에서 2점 이상을 받았을 때
TEXT_ONLY 위의 세 가지 조건 어디에도 해당하지 않는 나머지 모든 경우

* 복잡도 체크리스트(Complexity Checklist): 테이블 감지, 폼 위젯 존재, 이미지+텍스트 혼합, 블록 수가 30개 이상인 밀집 레이아웃, 글자 수는 50자가 넘지만 텍스트 구역 비율이 10% 미만인 양식(Form) 스타일인지 체크합니다. 여

기서 2개 이상 걸리면 바로 LLM 라우팅 트리거가 켜집니다.

이 규칙 세트에 문서를 통과시키면 각 페이지에 어떤 처리가 필요한지 명확해집니다. 예를 들어, 어떤 문서의 모든 페이지가 TEXT_ONLY 버킷으로 분류된다면 무거운 파이프라인을 거칠 필요 없이 PyMuPDF만으로 구조화된 텍스트를 바로 추출하면 끝입니다. 반면 LLM_NEEDED로 판별된다면 PyMuPDF Pro 같은 전문 파싱 솔루션으로 안전하게 전달하면 되겠죠.

 

구현 코드

코드가 조금 길어 보이지만, 복사해서 바로 쓸 수 있는 전체 소스 코드입니다. 각 페이지를 위에서 정의한 규칙 세트에 대입한 뒤 깔끔한 분류 리포트(Triage Report)를 출력해 줍니다.

코드 파일을 triage.py라는 이름으로 저장한 후 아래처럼 실행하시면 됩니다.

python triage.py input.pdf

조금 더 디테일한 리포트가 필요하다면 --details 옵션을 붙여보세요.

python triage.py input.pdf --details

 

자, 그럼 본격적으로 활용할 수 있는 triage.py 전체 소스코드를 공개합니다!

"""
PyMuPDF Page Triage — cheap signal extraction before OCR / LLM spend.

Strategy
--------
For each page we collect a small set of cheap signals, then assign it
to one of four triage buckets:

 SKIP        — blank / near-blank, not worth processing at all
 TEXT_ONLY   — native text is extractable, no OCR needed
 OCR_NEEDED  — image-heavy with little/no native text, send to OCR
 LLM_NEEDED  — requires semantic reasoning (forms, mixed layouts, tables, etc.)

Costs (approximate, relative)
 PyMuPDF signal extraction                 ~0.001x
 OCR (e.g. Tesseract/cloud)                ~1x
 LLM (e.g. PyMuPDF4LLM, GPT-4o, Claude)    ~2–50x
"""

from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Optional

import pymupdf


# ── Triage buckets 

class Bucket(Enum):
   SKIP       = auto()   # blank / negligible content
   TEXT_ONLY  = auto()   # native text, no further processing needed
   OCR_NEEDED = auto()   # image page or scanned, needs OCR
   LLM_NEEDED = auto()   # requires semantic reasoning


# ── Per-page signals 

@dataclass
class PageSignals:
   page_number:        int
   width:              float
   height:             float

   # Text
   char_count:         int   = 0
   word_count:         int   = 0
   text_coverage:      float = 0.0   # fraction of page bbox covered by text blocks
   has_native_text:    bool  = False

   # Images
   image_count:        int   = 0
   image_coverage:     float = 0.0   # fraction of page bbox covered by images

   # Structure hints
   has_tables:         bool  = False
   has_forms:          bool  = False   # detected via widget annotations
   block_count:        int   = 0
   vector_drawing:     bool  = False   # any non-image, non-text drawing commands

   # Derived
   bucket:             Optional[Bucket] = field(default=None, init=False)
   reason:             str              = field(default="",   init=False)


# ── Signal extraction 

def extract_signals(page: pymupdf.Page) -> PageSignals:
   """
   Extract cheap signals from a single PyMuPDF page object.
   All operations stay in Python/C; nothing is sent to an external service.
   """
   rect = page.rect
   page_area = rect.width * rect.height or 1.0  # guard /0

   sig = PageSignals(
       page_number=page.number, # zero-indexed page number
       width=rect.width,
       height=rect.height,
   )

   # Text
   # get_text("words") is faster than "blocks" for character/word counts
   words = page.get_text("words")          # list of (x0,y0,x1,y1,word,…)
   sig.word_count  = len(words)
   sig.char_count  = sum(len(w[4]) for w in words)
   sig.has_native_text = sig.char_count > 20   # ignore stray watermarks/footers

   # Text spatial coverage via blocks
   blocks = page.get_text("blocks")        # (x0,y0,x1,y1,text,block_no,block_type)
   sig.block_count = len(blocks)
   text_area = sum(
       (b[2] - b[0]) * (b[3] - b[1])
       for b in blocks if b[6] == 0        # block_type 0 = text
   )
   sig.text_coverage = min(text_area / page_area, 1.0)

   # Images
   # get_image_info() returns bbox data without extracting pixel data — very cheap
   images = page.get_image_info(hashes=False, xrefs=False)
   sig.image_count = len(images)
   img_area = sum(
       (img["bbox"][2] - img["bbox"][0]) * (img["bbox"][3] - img["bbox"][1])
       for img in images if img.get("bbox")
   )
   sig.image_coverage = min(img_area / page_area, 1.0)

   # Tables
   # PyMuPDF has find_tables(); use it
   tabs = page.find_tables()
   sig.has_tables = len(tabs.tables) > 0

   # Forms: widget annotations (checkboxes, text fields, dropdowns, etc.)
   for annot in page.annots():
       if annot.type[0] == pymupdf.PDF_ANNOT_WIDGET:
           sig.has_forms = True
           break

   # Vector drawings: any path/curve drawing that is not an image.
   # get_drawings() is cheap and returns strokes/fills.
   drawings = page.get_drawings()
   sig.vector_drawing = len(drawings) > 0

   return sig

# ── Triage rules ──────────────────────────────────────────────────────────────

def triage(sig: PageSignals,
          *,
          blank_char_threshold:    int   = 10,
          blank_image_threshold:   float = 0.02,
          ocr_image_threshold:     float = 0.25,
          ocr_text_threshold:      int   = 30,
          llm_complexity_score:    int   = 2) -> PageSignals:
   """
   Apply triage rules and attach bucket + reason to the signals object.

   Thresholds are keyword-only so callers can tune per document type.
   """

   chars  = sig.char_count
   imgcov = sig.image_coverage

   # ── Rule 1: SKIP — blank page 
   if chars < blank_char_threshold and imgcov < blank_image_threshold:
       sig.bucket = Bucket.SKIP
       sig.reason = f"blank (chars={chars}, img_cov={imgcov:.2f})"
       return sig

   # ── Rule 2: OCR_NEEDED — image-dominant, little/no native text 
   if imgcov >= ocr_image_threshold and chars < ocr_text_threshold:
       sig.bucket = Bucket.OCR_NEEDED
       sig.reason = (
           f"image-dominant (img_cov={imgcov:.2f}, chars={chars}) — "
           "likely scanned or image-only page"
       )
       return sig

   # ── Rule 3: LLM_NEEDED — structured/complex content 
   complexity = sum([
       sig.has_tables,
       sig.has_forms,
       sig.image_count > 0 and sig.has_native_text,   # mixed image+text
       sig.block_count > 30,                           # dense layout
       sig.text_coverage < 0.10 and chars > 50,       # sparse text (forms-like)
   ])
   if complexity >= llm_complexity_score:
       sig.bucket = Bucket.LLM_NEEDED
       sig.reason = (
           f"complex layout (complexity_score={complexity}/5): "
           + ", ".join(filter(None, [
               "tables"        if sig.has_tables else "",
               "forms"         if sig.has_forms  else "",
               "mixed content" if sig.image_count > 0 and sig.has_native_text else "",
               f"{sig.block_count} blocks" if sig.block_count > 30 else "",
               "sparse text"   if sig.text_coverage < 0.10 and chars > 50 else "",
           ]))
       )
       return sig

   # ── Rule 4: TEXT_ONLY — clean native text 
   sig.bucket = Bucket.TEXT_ONLY
   sig.reason = (
       f"native text (chars={chars}, words={sig.word_count}, "
       f"text_cov={sig.text_coverage:.2f})"
   )
   return sig


# ── Document-level triage 

@dataclass
class TriageReport:
   path:       str
   page_count: int
   results:    list[PageSignals]

   @property
   def by_bucket(self) -> dict[Bucket, list[PageSignals]]:
       out: dict[Bucket, list[PageSignals]] = {b: [] for b in Bucket}
       for r in self.results:
           out[r.bucket].append(r)
       return out

   def summary(self) -> str:
       bb = self.by_bucket
       lines = [
           f"Document : {self.path}",
           f"Pages    : {self.page_count}",
           "─" * 50,
       ]
       for bucket in Bucket:
           pages = bb[bucket]
           if not pages:
               continue
           nums = ", ".join(str(p.page_number) for p in pages[:10])
           if len(pages) > 10:
               nums += f" … (+{len(pages)-10} more)"
           lines.append(f"  {bucket.name:<12} {len(pages):>4} pages   [{nums}]")
       lines.append("─" * 50)

       total = self.page_count or 1
       skip  = len(bb[Bucket.SKIP])
       lines.append(
           f"  Skippable: {skip}/{total} ({skip/total*100:.0f}%)  "
           f"— estimated cost avoided relative to sending all to LLM"
       )
       return "\n".join(lines)


def triage_document(
   path: str | Path,
   **triage_kwargs,
) -> TriageReport:
   """
   Open a PDF and triage every page. Returns a TriageReport.
   The PDF is opened read-only; no modifications are made.
   """
   path = str(path)
   doc  = pymupdf.open(path)
   results = []

   for page in doc:
       sig = extract_signals(page)
       triage(sig, **triage_kwargs)
       results.append(sig)

   doc.close()
   return TriageReport(path=path, page_count=len(results), results=results)


# ── Routing helpers 

def route_pages(report: TriageReport) -> dict[str, list[int]]:
   """
   Return zero-indexed page numbers grouped by processing route.
   Plug these directly into your OCR / LLM pipeline.

   Usage
   -----
       routes = route_pages(report)
       ocr_pages  = routes["ocr"]    # send to Tesseract / cloud OCR
       llm_pages  = routes["llm"]    # send to PyMuPDF4LLM / GPT-4o / Claude etc.
       text_pages = routes["text"]   # extract with page.get_text() — free
       skip_pages = routes["skip"]   # ignore entirely
   """
   bb = report.by_bucket
   return {
       "skip":  [p.page_number for p in bb[Bucket.SKIP]],
       "text":  [p.page_number for p in bb[Bucket.TEXT_ONLY]],
       "ocr":   [p.page_number for p in bb[Bucket.OCR_NEEDED]],
       "llm":   [p.page_number for p in bb[Bucket.LLM_NEEDED]],
   }


def extract_text_pages(pdf_path: str | Path, page_indices: list[int]) -> dict[int, str]:
   """
   Cheaply extract native text from TEXT_ONLY pages.
   Returns {page_index: text}.
   """
   doc = pymupdf.open(str(pdf_path))
   out = {}
   for i in page_indices:
       out[i] = doc[i].get_text("text")
   doc.close()
   return out


def render_pages_for_ocr(
   pdf_path: str | Path,
   page_indices: list[int],
   dpi: int = 200,
) -> dict[int, bytes]:
   """
   Render OCR_NEEDED pages to PNG bytes at the given DPI.
   Returns {page_index: png_bytes} — pass directly to your OCR engine.

   Tip: 150 dpi is usually enough for Tesseract; 300 dpi for cloud APIs.
   """
   doc = pymupdf.open(str(pdf_path))
   out = {}
   mat = pymupdf.Matrix(dpi / 72, dpi / 72)
   for i in page_indices:
       pix = doc[i].get_pixmap(matrix=mat, colorspace=pymupdf.csGRAY)
       out[i] = pix.tobytes("png")
   doc.close()
   return out


# ── CLI entry point 

if __name__ == "__main__":
   import sys
   import json

   if len(sys.argv) < 2:
       print("Usage: python triage.py <file.pdf> [--details]")
       sys.exit(1)

   pdf_path   = sys.argv[1]
   show_details    = "--details" in sys.argv

   report = triage_document(pdf_path)

   if show_details:
       routes = route_pages(report)
       output = json.dumps({
           "path":       report.path,
           "page_count": report.page_count,
           "routes":     routes,
           "details": [
               {
                   "page":          s.page_number,
                   "bucket":        s.bucket.name,
                   "reason":        s.reason,
                   "chars":         s.char_count,
                   "words":         s.word_count,
                   "images":        s.image_count,
                   "image_cov":     round(s.image_coverage, 3),
                   "text_cov":      round(s.text_coverage, 3),
                   "has_tables":    s.has_tables,
                   "has_forms":     s.has_forms,
                   "has_vector":        s.vector_drawing,
               }
               for s in report.results
           ]
       }, indent=2)

       print(output)

   else:
       print(report.summary())
       print()
       for s in report.results:
           print(f"  p{s.page_number:>4}  [{s.bucket.name:<12}]  {s.reason}")

 

실제 실행 결과는 어떨까? 

위 코드를 실행하면 각 페이지별 상태가 실시간으로 매겨집니다. 이 데이터를 기반으로 개발자는 페이지별 맞춤형 후처리를 자동화할 수 있죠.

예를 들어 3페이지짜리 문서를 넣었을 때의 로그 출력 예시입니다.

p    0 [OCR_NEEDED  ] image-dominant (img_cov=1.00, chars=14) — likely scanned or image-only page
p    1 [TEXT_ONLY   ] native text (chars=1362, words=93, text_cov=0.34)
p    2 [LLM_NEEDED  ] complex layout (complexity_score=2/5): mixed content, 43 blocks

이 결과를 뜯어보면 참 재밌는 인사이트를 얻을 수 있습니다.

  • 첫 번째 페이지(p0)는 이미지 비중이 100%인 걸 보니 아마 브랜드 로고나 그래픽만 들어간 '표지(커버)'일 확률이 높겠네요. 굳이 무거운 클라우드 OCR을 다 태울 필요 없이 가볍게 텍스트만 읽어보고 패스하거나 가성비 좋게 처리할 수 있습니다.
  • 두 번째 페이지(p1)는 평범한 디지털 본문이니 비용이 들지 않는 기본 get_text() 메서드로 텍스트만 깔끔하게 쏙 빼오면 됩니다.
  • 세 번째 페이지(p2)는 한눈에 봐도 빽빽하고 복잡한 구조네요. 이 페이지야말로 AI 레이아웃 분석이 탑재된 PyMuPDF Pro 솔루션을 투입해 정밀하게 파싱해야 하는 '진짜 타겟'입니다.

 

결론

세상의 모든 문서 파이프라인은 저마다의 크고 작은 예외들을 가지고 있습니다. 오늘 보여드린 예제 코드 역시 정답이라기보다는 빌딩 블록을 쌓기 위한 '시작점'에 가깝습니다.

여러분이 다루는 실제 데이터의 특성에 맞춰 스캔 페이지 판정 비율을 조정하거나, 복잡도 점수 규칙을 정교화하고, 우리 도메인에 특화된 시그널을 추가해 보세요. 분류 레이어의 정확도가 드라마틱하게 올라갈 것입니다.

수많은 대량의 문서를 만지는 파이프라인에서 이런 사전 분류(Triage) 단계를 한 번만 제대로 구축해 두면, 불필요한 OCR과 LLM 호출을 획기적으로 줄여 매달 나가는 토큰 비용을 눈에 띄게 아낄 수 있습니다.

 

[참고 자료]

  • PyMuPDF Pro: 전 세계 개발자가 선택한 Python 기반 데이터 추출 라이브러리