PDF RAG가 실패하는 이유: 레이아웃 인식 청킹(Layout-Aware Chunking)이 해답이다
요약
PDF 기반 RAG 시스템에서 단순 문자 기반 청킹이 초래하는 데이터 왜곡 문제를 분석하고, 이를 해결하기 위한 레이아웃 인식 청킹(Layout-Aware Chunking)의 중요성을 설명합니다. 표, 열, 각주 등 PDF의 구조적 요소를 보존하여 검색 정확도를 높이는 전략을 제시합니다.
핵심 포인트
- 단순 문자 기반 청킹은 PDF의 구조를 파괴하여 환각을 유발함
- 레이아웃 인식 청킹은 표 기반 질문의 검색 실패율을 38%에서 6%로 낮춤
- PDF는 산문이 아닌 좌표 기반의 페인트 형식으로 취급해야 함
- 읽기 순서 탐지(Reading order detection)가 고성능 RAG의 핵심 레이어임
도서: RAG Pocket Guide: Retrieval, Chunking, and Reranking Patterns for Production
저자의 다른 저서: Thinking in Go (2권 시리즈) — Complete Guide to Go Programming + Hexagonal Architecture in Go
내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하는 개발자를 위한 IDE
나: xgabriel.com | GitHub
대부분의 "RAG가 우리에게는 작동하지 않았다"라는 이야기는 사실 "우리는 PDF에 RecursiveCharacterTextSplitter를 사용했다"라는 이야기입니다. 해결책은 더 나은 모델이 아닙니다. 당신의 파이프라인(Pipeline)에 없는 네 가지 레이어(Layer)입니다.
40%라는 수치: 신호가 소멸하는 지점
전형적인 SEC 10-K 보고서를 예로 들어봅시다. 본문은 두 개의 열(Column)로 구성되어 있습니다. 모든 페이지마다 반복되는 푸터(Footer)가 있고, 하단에는 각주(Footnote)가 있습니다. 세 페이지에 걸쳐 있는 표(Table)가 있고, 주변 단락의 절반을 설명하는 캡션(Caption)이 달린 그림(Figure)이 있습니다.
이를 PyPDF와 RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)로 처리하면 다음과 같은 결과가 나옵니다: 3페이지의 왼쪽 열과 3페이지의 오른쪽 열이 서로 이어지고, 페이지 푸터가 문장 중간에 끼어들며, 표는 행 정렬 없이 Q1 Q2 Q3 Q4 Revenue 12 14 11 16 Expenses 8 9 7 10 와 같이 렌더링되고, 그림 캡션은 설명하려는 단락으로부터 800 토큰이나 떨어진 곳에 고립됩니다.
그다음 누군가 "Q3 매출은 얼마였나?"라고 묻습니다. 당신의 리트리버(Retriever)는 엉망이 된 표가 포함된 청크(Chunk)를 반환합니다. 모델은 Q3 다음에 나오는 숫자인 $11M이라고 환각(Hallucination)을 일으킵니다. 실제로는 $11B입니다. 단위(Unit)가 포함된 행이 잘려 나갔기 때문입니다.
4,000개의 금융 보고서 코퍼스(Corpus)를 대상으로 한 발표된 연구에 따르면, 단순한 문자 기반 청킹(Character-chunked) 검색은 표에 기반한 질문에서 38%의 확률로 올바른 범위를 놓쳤습니다. 반면 레이아웃 인식 청킹(Layout-aware chunking)은 그 수치를 6%로 낮추었습니다. 동일한 임베딩 모델(Embedding model), 동일한 리트리버(Retriever), 동일한 리랭커(Reranker)를 사용했음에도 말입니다.
PDF는 콘텐츠 형식이 아닙니다. 그것은 프린터를 위한 (x, y, glyph) 튜플 형태의 페인트 형식입니다. 이를 산문(Prose)처럼 취급하는 것이 바로 버그입니다. 해결책은 네 가지의 직교하는 레이어(Orthogonal layers)입니다. 그 중 어느 것도 "더 나은 모델을 사라"는 것이 아닙니다.
레이어 1: 읽기 순서 탐지 (Reading order detection)
PDF는 글리프(Glyphs)를 읽기 순서가 아닌 그리기 순서(Draw order)로 저장합니다.
렌더러(Renderer)는 어떤 열(Column)이 먼저 오는지 상관하지 않습니다. 하지만 여러분의 청커(Chunker)는 상관해야 합니다. 읽기 순서(Reading order)란 N개의 텍스트 블록이 있는 페이지가 주어졌을 때, 사람이 읽는 순서대로 블록을 반환하는 것을 의미합니다. 두 개의 열을 읽고, 그다음 각주(Footnotes)를 읽고, 마지막으로 페이지 푸터(Page footer)를 읽는 식입니다. "글리프(Glyph) 좌표 기준 왼쪽 상단에서 오른쪽 하단으로"가 아닙니다.
도구들이 이를 처리하는 방식:
- 휴리스틱(Heuristic) 방식: x-위치(x-position)를 기준으로 블록들을 열 단위로 클러스터링(Cluster)한 다음, 각 열 내에서 위에서 아래로 정렬합니다. 다단(Multi-column) 문서의 80% 정도에는 효과적입니다. 하지만 회전된 페이지, 사이드바(Sidebar), 콜아웃 박스(Callout boxes)에서는 실패합니다.
- 머신러닝(ML) 기반 방식: 레이아웃 탐지 모델(LayoutLMv3, DiT, 또는 Docling의 레이아웃 파서(Layout parser))을 실행하여 모든 블록을 텍스트(Text), 제목(Title), 리스트(List), 표(Table), 그림(Figure), 각주(Footnote), 페이지 헤더(Page-header), 페이지 푸터(Page-footer)로 분류합니다. 그 다음 의미적 역할(Semantic role)에 따라 정렬합니다.
잘못된 경로 건너뛰기: 청킹(Chunking)을 하기 전에 페이지 헤더(Page-header)와 페이지 푸터(Page-footer)로 분류된 모든 항목을 제거하세요. 이들은 모든 페이지에서 반복되어 임베딩(Embeddings)을 오염시킵니다.
주의해야 할 작은 함정: 일부 라이브러리는 이를 "읽기 순서(Reading order)"라고 부르지만, 실제로는 블록을 (y, x) 순서로 정렬하여 반환합니다. 그것은 읽기 순서가 아닙니다. 그것은 래스터 순서(Raster order)입니다. 이를 신뢰하기 전에 반드시 2단 구성의 논문으로 테스트해 보세요.
레이어 2: 토큰 수(Token count)가 아닌 섹션(Section)에 의한 구조적 청킹(Structural chunking)
읽기 순서와 블록 역할(Block roles)을 확보했다면, 이제 토큰 단위의 청킹을 중단하세요. 문서 구조에 따라 청킹해야 합니다.
규칙: 하나의 청크(Chunk)는 하나의 섹션(Section)입니다. 섹션은 동일하거나 더 높은 수준의 다음 헤딩(Heading)이 나타날 때, 또는 표(Table)나 그림(Figure)이 나타날 때 종료됩니다. 부모 헤딩 체인(Parent heading chain)을 메타데이터(Metadata)로 추가하면 검색(Retrieval) 시 컨텍스트(Context)를 무료로 얻을 수 있습니다.
@dataclass
class Chunk:
doc_id: str
text: str
section_path: list[str] # ["3. Risk Factors", "3.2 Liquidity"]
page_range: tuple[int, int]
block_type: str # "text" | "table" | "figure_caption"
source_bbox: list[tuple] # 인용(Citation)을 위한 용도
이 방식이 고정 크기 청크(Fixed-size chunks)보다 뛰어난 이유: "3.2 유동성 리스크(Liquidity Risk)" 섹션에 대한 질의를 던지면, 이제 "예산이 다 떨어지기 전 마지막 700개 토큰"이 아니라 실제로 해당 섹션인 청크에 도달하게 됩니다. section_path는 검색 가능한 메타데이터가 됩니다. 긴 형식의 문서(Long-form documents)에서 벡터 검색(Vector search)을 수행하기 전에 섹션별로 사전 필터링(Pre-filter)을 할 수 있습니다.
절(clause) 중간에서 문장을 끊는 일을 멈추십시오. 토큰 경계(Token boundaries)는 임의적이지만, 섹션 경계(Section boundaries)는 그렇지 않습니다. 컨텍스트 예산(Context budget)을 초과하는 섹션은 여전히 분할해야 합니다. 이때 문자 오프셋(Character offsets)이 아닌 단락 경계(Paragraph boundaries)에서 분할하고, 검색(Retrieval) 시 함께 클러스터링될 수 있도록 모든 서브 청크(Sub-chunk)에 섹션 경로(Section path)를 유지하십시오. 한 가지 주의할 점은, 섹션이 최소 청크 크기보다 짧은 경우(예: 30단어 정도의 각주) 이를 버리지 마십시오. 관련 없는 이웃 섹션과 병합해서도 안 됩니다. 작은 청크로 유지하십시오. 강력한 관련성 신호(Relevance signal)를 가진 작은 청크가 거대한 청크보다 언제나 더 낫습니다.
레이어 3: 별도 문서로서의 표 추출 (Table extraction as separate documents)
표(Tables)는 단순한(Naive) PDF RAG가 실패하는 가장 큰 원인입니다. 문자 단위 청커(Character chunker)에게 표는 산문(Prose)처럼 보이지만, 실제로는 그렇지 않습니다. 모든 표를 두 가지 표현 방식(Representations)을 가진 별도의 문서로 추출하십시오:
- 전체 표를 하나의 청크로 유지하는 마크다운 렌더링(Markdown rendering): "이 표를 요약해줘"와 같은 질문에 유용합니다.
- 행 단위 문서(Row-level documents): 표의 캡션(Caption)과 열 헤더(Column headers)를 앞에 붙여 각 행마다 하나씩 생성합니다. "3분기 매출은 얼마였나?"와 같은 질문에 유용합니다.
# Docling 추출 예시
for table in doc.tables:
# 마크다운 방식
md = table.to_markdown()
chunks.append(
Chunk(
text=f" Table: {table.caption} \n\n {md}",
block_type="table",
section_path=table.section_path,
page_range=(table.page, table.page),
)
)
# 행 단위 방식: 각 행이 헤더 컨텍스트를 포함함
headers = table.headers
for row in table.rows:
row_text = "\n".join(f"{h}: {v}" for h, v in zip(headers, row))
chunks.append(
Chunk(
text=f" Table: {table.caption} \n{row_text}",
block_type="table_row",
section_path=table.section_path,
page_range=(table.page, table.page),
)
)
중복은 괜찮습니다. 두 가지 표현 방식 모두 도움이 됩니다. 리트리버(Retriever)는 쿼리가 원하는 것을 선택할 것입니다. 항상 잘못되는 두 가지 사항은 다음과 같습니다: 여러 페이지에 걸친 표(Multi-page tables)는 2페이지 이후부터 헤더를 잃어버립니다.
페이지가 표 중간에서 시작되는지(이전 헤딩이 없고, 이전 페이지와 열 구조가 일치하는지) 확인하여 "표가 계속됨(table continued)"을 감지하고, 헤더를 앞으로 전파(propagate)하십시오. 병합된 셀(Merged cells)은 행 정렬을 망가뜨립니다. Docling과 LlamaParse는 이를 상당히 잘 처리합니다. PyMuPDF의 표 추출(table extraction)은 그렇지 않습니다. 셀을 한 열씩 조용히 밀어버릴 것입니다.
레이어 4: 이미지 OCR 및 그림 캡션(Figure captioning) 두 가지 경우가 있습니다. 텍스트 선택이 가능한 네이티브 PDF(Native PDFs)의 경우: OCR을 건너뛰십시오. 이미 텍스트를 가지고 있습니다. 페이지가 이미지인 스캔된 PDF(Scanned PDFs)의 경우: 모든 페이지에 OCR을 적용합니다. 네이티브 PDF 내부의 그림(차트, 다이어그램, 스크린샷)의 경우, OCR만으로는 충분하지 않습니다. 막대 그래프의 축 레이블(axis labels)에 있는 텍스트만으로는 차트가 무엇을 보여주는지 설명할 수 없습니다. VLM (Claude, GPT-4o, Qwen-VL)을 사용하여 이미지로부터 캡션(caption)을 생성한 다음, 해당 캡션을 그림의 경계 상자(bbox)와 연결된 청크(chunk)로 저장하십시오.
def caption_figure ( image_bytes : bytes , surrounding : str ) -> str :
# surrounding = 그림의 바로 앞 또는 뒤에 있는 문단입니다.
# VLM에 문맥(context)을 제공합니다.
return vlm . describe ( image_bytes , prompt = ( " 이 그림을 2~3문장으로 설명하세요. " " 보이는 숫자, 축 레이블, " " 추세 방향을 포함하세요. 문맥을 위해 주변 텍스트를 사용하세요: " f " context: \n\n { surrounding } " ), )
"지역별 3분기 매출을 보여주는 막대 그래프: 미주 $12B, EMEA $7B, APAC $4B. 미주는 전년 대비 18% 증가함"과 같은 캡션은 검색(retrievable)이 가능합니다. 원래의 PNG 파일은 그렇지 않습니다.
여기서는 비용 관리(Cost discipline)가 중요합니다. 4,000개의 문서 코퍼스(corpus)에 있는 모든 그림을 GPT-4o로 캡션하면 비용이 네 자릿수(달러) 범위에 달합니다. 데이터 수집(ingestion) 시점에 캡션을 한 번만 실행하십시오. 캐싱(Cache)하십시오. 그림이 변경되지 않는 한 재색인(re-index) 시 캡션을 다시 생성하지 마십시오.
문서 유형에 맞는 적절한 레이어 선택하기
모든 문서에 네 가지 레이어가 모두 필요하지는 않습니다. 각 레이어는 직교(orthogonal)하지만, 문서마다 서로 다른 실패 모드(failure modes)를 가집니다.
| 문서 유형 | 읽기 순서 (Reading order) | 구조적 (Structural) | 표 (Tables) | OCR / 캡션 (captions) |
|---|---|---|---|---|
| 법률 계약서 (born-digital) | 필수 (required) | 필수 (required) | 선택 사항 (optional) | 건너뜀 (skip) |
| 학술 논문 (2단 구성) | 필수 (required) | 필수 (required) | 필수 (required) | 필수 (required) (그림) |
| SEC 공시 (10-K, 10-Q) | 필수 (required) | 필수 (required) | 필수 (required) | 선택 사항 (optional) |
| 스캔된 계약서 / 역사적 문서 | 필수 (required) | (OCR 후) 부분적 (partial) | 부분적 (partial) | 필수 (required) (전체) |
| PDF로 내보낸 슬라이드 덱 | 부분적 (partial) | 건너뜀 (skip) | 부분적 (partial) | 필수 (required) (그림) |
| PDF로 내보낸 내부 위키 | 선택 사항 (optional) | 필수 (required) | 선택 사항 (optional) | 건너뜀 (skip) |
| 송장 및 영수증 | 부분적 (partial) | 건너뜀 (skip) | 필수 (required) | 스캔된 경우 필수 (required if scanned) |
이 매트릭스를 실제 운영 환경(production)에서 실행하며 얻은 몇 가지 솔직한 관찰 결과입니다:
- 계약서는 90%가 구조적(structural)입니다. 섹션, 하위 섹션, 정의가 핵심입니다. 조항(clauses)을 전체 조항 경로(clause path)를 메타데이터로 포함하는 개별 청크(discrete chunks)로 가져오면 재현율(recall)이 즉시 급상승합니다.
- 논문은 가치의 50%가 표와 그림(table-and-figure)에서 나옵니다. arXiv PDF 코퍼스(corpus)에서 레이어 3과 레이어 4를 건너뛴다면, 인용 가능한 콘텐츠의 대부분을 버리는 것과 같습니다.
- 슬라이드 덱은 독특합니다. 시각적 구조가 곧 문서입니다. '제목과 세 개의 불렛 포인트'가 있는 슬라이드는 하나의 청크입니다. 다이어그램 슬라이드는 하나의 VLM 캡션입니다. 읽기 순서(reading order)는 잊으세요.
라이브러리 지도: 2026년에는 무엇을 사용해야 하는가
| 라이브러리 | 읽기 순서 (Reading order) | 구조적 (Structural) | 표 (Tables) | OCR / VLM | 비용 / 속도 | 비고 |
|---|---|---|---|---|---|---|
| Docling (IBM, OSS) | 매우 우수 (Excellent) | 매우 우수 (Excellent) | 매우 우수 (Excellent) | 내장 OCR, 외부 VLM | 무료 (Free), GPU 권장 | 최고의 범용 OSS 옵션. 섹션 트리와 표 행을 포함한 구조화된 JSON을 출력함. 기본 선택지(Default pick). |
| unstructured.io | 좋음 (Good) | 좋음 (Good) | 단순한 표에 좋음 (Good for simple tables) | Tesseract/PaddleOCR를 통한 OCR | 무료 OSS, 유료 API | 성숙함. 이질적인 코퍼스(heterogeneous corpora)에 강함. 표 품질은 Docling보다 뒤처짐. |
| LlamaParse | 매우 우수 (Excellent) | 매우 우수 (Excellent) | 최고 수준 (Best-in-class) | 내장 VLM 캡셔닝 | 유료, 약 $3/1k 페이지 | 표가 병목(bottleneck)이고 페이지당 비용을 지불할 수 있다면, 가장 깔끔한 결과물을 제공함. 벤더 종속(Vendor lock-in) 발생. |
| PyMuPDF | 수동 (Manual) | 수동 (Manual) | 수동 (Manual) | 없음 (None native) | 무료 (Free), 매우 빠름 | 저수준(Low-level). 파서 기본 요소(parser primitive)로서 훌륭함. 가공되지 않은 page.get_text()를 RAG에 그대로 보내지 마세요. |
| PyPDF / pdfplumber | 수동 (Manual) | 수동 (Manual) | pdfplumber: 준수함 (decent) | 없음 (None) | 무료 (Free), 빠름 | 대부분의 프로젝트가 시작되는 지점. |
대부분의 프로젝트가 막히는 지점입니다.
| Marker (OSS) | 매우 우수 (Excellent) | 매우 우수 (Excellent) | 좋음 (Good) | 내장 OCR + 수학 (Built-in OCR + math) | 무료 (Free), GPU 필요 | 학술용 PDF 및 수학 공식에 강력함. CPU 환경에서는 Docling보다 느림. |
| AWS Textract / Azure DI | 좋음 (Good) | 부분적 (Partial) | 매우 우수 (Excellent) | 내장 OCR (Built-in OCR) | 페이지당 유료 (Paid per page) | 규정 준수에 용이함. 레이아웃 결정 방식이 블랙박스임. 스캔된 양식에 좋음. |
솔직한 평가: 2026년 중반 기준으로, Docling이 기본값(default)입니다. 예산이 허락한다면 LlamaParse가 원시 테이블(raw table) 품질 면에서 승리합니다. PyMuPDF는 파이프라인(pipeline)이 아니라 원시 도구(primitive)입니다.
80줄로 구현한 참조 파이프라인 (A reference pipeline in 80 lines)
이것이 모든 레이아웃 인식 인제스션 파이프라인(layout-aware ingestion pipeline)이 최종적으로 갖게 되는 형태입니다. 파싱(parsing)에는 Docling을 사용하고, 그림 캡션(figure captions)에는 VLM 호출을 사용합니다. 이 80줄의 코드가 전체 프로덕션 시스템(재시도 로직, 배치 처리, 비용 계산 등이 없음)은 아니지만, 중요한 것은 바로 이 구조입니다.
from dataclasses import dataclass
from typing import Iterator
from docling.document_converter import DocumentConverter
from anthropic import Anthropic
vlm = Anthropic()
@dataclass
class Chunk:
doc_id: str
text: str
section_path: list[str]
page_range: tuple[int, int]
block_type: str
bbox: list[tuple] | None
def ingest(path: str, doc_id: str) -> Iterator[Chunk]:
result = DocumentConverter().convert(path)
doc = result.document
# 페이지 헤더/푸터(page headers/footers)를 미리 제거
blocks = [b for b in doc.iterate_items() if b.label not in {"page-header", "page-footer"}]
section_stack: list[str] = []
buffer: list[str] = []
buffer_pages: set[int] = set()
def flush() -> Chunk | None:
if not buffer:
return None
c = Chunk(
doc_id=doc_id,
text="\n\n".join(buffer),
section_path=list(section_stack),
page_range=(min(buffer_pages), max(buffer_pages)),
block_type="text",
bbox=None,
)
buffer.clear()
buffer_pages.clear()
return c
for b in blocks:
if b.label in {"title", "section-header"}:
chunk = flush()
if chunk:
yield chunk
level = b.
level if hasattr(b, "level") else 1
section_stack = section_stack[:level - 1]
section_stack.append(b.text)
elif b.label == "table":
chunk = flush() # 만약 기존 chunk가 있다면 yield
if chunk:
yield chunk
md = b.export_to_markdown()
yield Chunk(doc_id=doc_id, text=f"Table: {b.caption or ''}\n\n{md}", section_path=list(section_stack), page_range=(b.page, b.page), block_type="table", bbox=[b.bbox]),
for row_md in b.export_rows_markdown():
yield Chunk(doc_id=doc_id, text=f"{b.caption or ''}\n{row_md}", section_path=list(section_stack), page_range=(b.page, b.page), block_type="table_row", bbox=[b.bbox]),
elif b.label == "figure":
chunk = flush() # 만약 기존 chunk가 있다면 yield
if chunk:
yield chunk
surrounding = b.surrounding_text or ""
caption = caption_figure(b.image_bytes, surrounding)
yield Chunk(doc_id=doc_id, text=f"Figure: {caption}", section_path=list(section_stack), page_range=(b.page, b.page), block_type="figure_caption", bbox=[b.bbox]),
else:
buffer.a
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기