TL;DR: LLM에게 “어디부터 어디까지"만 묻고, 텍스트는 서버가 직접 꺼내면 됩니다. 3페이지 실측 기준 Output 토큰 90% 감소, 레이턴시 87% 감소, 비용 61% 절감.
배경: Docling에서 PyMuPDF + VLM으로
건축 법규 검토 AI를 만들면서, 건축 고시/지침 PDF를 의미 단위의 청크(chunk)로 나눠야 했습니다. RAG 파이프라인의 검색 단위로 사용하기 위해서입니다.
처음에는 IBM의 Docling을 사용했습니다. OCR 모델로 문서 구조를 파악한 뒤 청킹하는 방식인데, 두 가지 문제가 있었습니다:
- 무거움: OCR·레이아웃 분석 모델(RT-DETR 등)이 포함되어 Docker 이미지 크기와 처리 시간이 큼
- 커스텀 어려움: 내부 파이프라인이 블랙박스에 가까워, 건축 문서 특유의 계층 구조(장 > 조 > 항 > 호)나 표 처리를 세밀하게 제어하기 어려움
그래서 OCR 모델을 걷어내고 PyMuPDF로 텍스트와 폰트 메타데이터를 직접 추출하는 방식으로 전환했습니다. 구조 분석은 멀티모달 LLM(VLM)의 Vision 기능으로 대체하면 OCR 의존성을 완전히 제거하면서도 커스텀이 자유롭습니다.
PDF → PyMuPDF 텍스트 추출 → 페이지 분류 → LLM 구조 분석 → 청크 생성 → 임베딩
아래는 실제 처리 대상인 건축 고시 PDF — 대구법원종합청사 설계공모 지침서의 본문 3페이지입니다.
| 3페이지 | 4페이지 | 5페이지 |
|---|---|---|
![]() |
![]() |
![]() |
“1. 공모의 목적”, “2. 사업의 개요(가마)”, “3. 공모 요강(가마)” 등 계층적 섹션 구조가 있는 본문입니다. 이것을 LLM이 의미 단위로 자동 분할하는 것이 목표인데 — 여기서 새로운 문제가 생겼습니다.
문제: LLM Output 토큰 비용
LLM API에서 Output 토큰 단가는 Input의 3~5배입니다.
| 모델 | Input (/1M tokens) | Output (/1M tokens) | 배율 |
|---|---|---|---|
| Claude Haiku 4.5 | $1.00 | $5.00 | 5x |
| Claude Sonnet 4.6 | $3.00 | $15.00 | 5x |
가장 단순한 접근은 LLM에게 분할된 섹션의 내용을 그대로 다시 출력하게 하는 것입니다:
[Input] "1. 공모의 목적\n설계자들에게 참여의 기회를..."
[Output] {"heading": "1. 공모의 목적", "text": "설계자들에게 참여의 기회를..."}
↑ 입력을 거의 그대로 반복
Output ≈ Input이 되니, 54페이지 문서 기준 Output만 수만 토큰이 발생할 것이 뻔했습니다. Output 단가가 5배인 구조에서, 이대로면 전체 비용의 80% 이상이 Output이 됩니다.
해결: 정수 인덱스 참조 방식
핵심 아이디어는 간단합니다:
LLM에게 텍스트의 시작/끝 인덱스만 출력하게 하고, 원본 텍스트는 서버에서 직접 조회한다.
실제 데이터로 보면 바로 이해됩니다:
[LLM Input — 78라인, 3,897 tokens]
[page 3]
0 <<h:15.0pt>> 1. 공모의 목적
1 <<p:12.0pt>> 설계자들에게 참여의 기회를 제공하고 공정한 경쟁을 통하여 우수한 설계안(공모지침에
2 <<p:12.0pt>> 충실하면서도 청사의 외관은 부지 주변의 경관과 조화를 이루고...
...
5 <<h:15.0pt>> 2. 사업의 개요
6 <<p:13.0pt>> 가. 건물의 명칭
7 <<p:12.0pt>> 대구법원종합청사
...
31 <<h:15.0pt>> 3. 공모 요강
...
(3페이지에 걸쳐 총 78라인)
[LLM Output — 190 tokens]
[
{"heading": "1. 공모의 목적", "start": 0, "end": 4},
{"heading": "2. 사업의 개요", "start": 5, "end": 30},
{"heading": "3. 공모 요강", "start": 31, "end": 43},
{"heading": "마. 심사", "start": 44, "end": 67},
{"heading": "바. 참가 보수", "start": 68, "end": 74}
]
heading 이름 + 정수 2개. 원본 텍스트는 서버 측 line_list[start:end+1]로 직접 조회하므로, LLM이 다시 출력할 필요가 없습니다.
실측 결과
Claude Haiku 4.5 (Bedrock Converse API)로 위 3~5페이지를 두 가지 방식으로 실제 처리한 결과입니다. 동일한 입력 텍스트에 대해 “섹션 내용을 그대로 출력” vs “인덱스만 출력” 프롬프트만 다르게 했습니다.
| 텍스트 전체 출력 | 인덱스 참조 | 감소율 | |
|---|---|---|---|
| Output tokens | 1,865 | 190 | 90% |
| 레이턴시 | 16.8초 | 2.2초 | 87% |
| 비용 | $0.0123 | $0.0048 | 61% |
Output 토큰이 줄면 비용만 줄어드는 게 아닙니다. LLM의 응답 시간은 생성하는 토큰 수에 비례하므로, Output이 1,865 → 190으로 줄면 레이턴시도 16.8초 → 2.2초로 87% 감소합니다. 3페이지에서 14초 차이면, 54페이지 문서에서는 분 단위 차이가 됩니다.
비용 절감도 중요하지만, 문서 처리 대기 시간이 수 배 줄어드는 것이 사용자 경험에는 더 큰 차이를 만듭니다.
구현 상세
인덱스가 부여된 어노테이션 텍스트 생성
def page_to_indexed_text(page_data: dict, line_counter: int = 0):
"""구조화 텍스트 → 정수 인덱스 포함 어노테이션 (LLM 입력용)"""
output_lines = [f"[page {page_data['page_num']}]"]
line_list = []
for block in page_data["blocks"]:
for line in block["lines"]:
first_span = line["spans"][0]
size, bold = first_span["size"], first_span["bold"]
is_heading = bold or size >= 14
style = "h" if is_heading else "p"
suffix = ",bold" if bold else ""
tag = f"<<{style}:{size}pt{suffix}>>"
text = " ".join(s["text"] for s in line["spans"])
line_list.append({
"text": text, "page": page_data["page_num"],
"is_heading": is_heading, "bbox": _union_bbox(line), # 생략
})
output_lines.append(f"{line_counter} {tag} {text}")
line_counter += 1
return "\n".join(output_lines), line_list, line_counter
line_list에 원본 텍스트·페이지 번호·bbox를 저장하고, LLM에게 보내는 텍스트에는 인덱스 번호와 폰트 어노테이션을 붙입니다. LLM이 {"start": 5, "end": 30}을 반환하면, 서버에서 line_list[5:31]을 꺼내면 됩니다.
프롬프트
TEXT_CHUNKING_PROMPT = """<role>
건축 관련 고시/지침 PDF를 분석하여 논리적 섹션으로 분할하는 전문가입니다.
</role>
<input>
각 라인 앞에 0, 1, 2 같은 인덱스 번호가 부여되어 있습니다.
섹션의 시작과 끝 인덱스만 지정하면 됩니다.
</input>
<output_format>
JSON 배열만 출력하세요.
[{{"heading": "섹션 제목", "start": 시작_인덱스, "end": 끝_인덱스}}]
</output_format>
<text>
{text}
</text>"""
텍스트 외 페이지에도 동일 패턴 적용
이 인덱스 참조 패턴은 텍스트 페이지뿐 아니라 표, 혼합(텍스트+표) 페이지에도 동일하게 적용됩니다. 형태만 다를 뿐 원리는 같습니다:
| 페이지 유형 | LLM에게 주는 인덱스 | LLM이 출력하는 것 | 서버가 복원하는 것 |
|---|---|---|---|
| 텍스트 | 라인 인덱스 0, 1, 2... |
start, end |
line_list[start:end+1] |
| 표 | 표/행 인덱스 [표 0], 0:, 1:... |
index, header_row |
tables_data[index][row] |
| 혼합 | 블록 ID [B0], [B1]... |
block_refs: [0, 1] |
page_data["blocks"][i] |
표 페이지에서는 PyMuPDF find_tables()로 셀 데이터를 추출한 뒤 행 인덱스를 부여하고, 혼합 페이지에서는 텍스트 블록 단위로 [B0], [B1]... ID를 부여합니다. LLM은 어떤 유형이든 정수 기반으로 위치를 참조하고, 서버가 원본을 조회합니다.
추가 기능: 정밀한 PDF 하이라이팅
line_list에 텍스트뿐 아니라 bbox(좌표)도 함께 저장했기 때문에, LLM 출력의 인덱스 범위로 해당 섹션의 정확한 PDF 영역을 바로 계산할 수 있습니다. 텍스트 전체 출력 방식이었다면 LLM 출력을 원본 PDF에서 다시 매칭해야 했겠지만, 인덱스 방식에서는 line_list[i].bbox로 즉시 조회됩니다.
아래는 위 PoC의 5개 섹션 bbox를 실제 PDF 위에 오버레이한 결과입니다. 각 색상이 하나의 섹션에 대응하며, 페이지를 넘나드는 섹션(예: “2. 사업의 개요"가 3~4페이지에 걸침)도 정확히 추적됩니다.

정리
| 텍스트 전체 출력 | 인덱스 참조 | |
|---|---|---|
| Output tokens | 1,865 | 190 (90% 감소) |
| 레이턴시 | 16.8초 | 2.2초 (87% 감소) |
| 비용 | $0.0123 | $0.0048 (61% 감소) |
| 환각 위험 | 텍스트 변형 가능 | 원본 그대로 사용 |
| 구현 복잡도 | 단순 | 인덱싱 전처리 필요 |
LLM을 사용해서 문서를 분류하거나 분할할 때, “결과를 다시 써달라"고 하는 대신 “어디인지만 알려달라"고 하면 Output 토큰을 극적으로 줄일 수 있습니다. 비용뿐 아니라 응답 속도까지 함께 줄어들기 때문에, Output 단가가 Input의 수 배인 현재 요금 구조에서 그 효과는 배가됩니다.
이 테크닉은 건축 법규 검토 AI 시스템의 문서 전처리 파이프라인에서 PyMuPDF + Claude Haiku 기반으로 구현되어 실제 운영 중입니다.


