2026-05-22 강의 정리
비정형 문서 파싱 - unstructured 라이브러리 활용 2
목차
- 문제와 접근
- 환경 설정과 실습 파일
- PyMuPDF로 페이지와 텍스트 블록 확인
- PyMuPDF 표 추출과 analyze_tables
- Camelot으로 표 추출 결과 비교
- unstructured hi_res로 Table 요소 추출
- clean_table — 헤더·결측값·숫자 정제
- table_to_markdown — 표를 LLM 입력으로 변환
- PDF 이미지 추출과 파일 저장
- analyze_image — Vision LLM으로 OCR과 요약
- VisionOCR — 여러 이미지 처리와 Document 변환
- 핵심 개념 총정리
- 자주 나오는 실수/주의사항
- 추출 결과를 채택하는 기준
1. 문제와 접근
PDF의 표와 이미지를 검색에 사용하려면 추출 성공과 정보 보존을 따로 확인해야 한다. 같은 문서에 여러 파서를 적용하고, 헤더·수치·단위를 유지한 텍스트로 변환하는 과정을 살펴본다. OCR 결과도 원본을 다시 찾을 수 있는 메타데이터와 함께 다루는 데 초점을 둔다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PDF
├─ 표 추출
│ ├─ PyMuPDF
│ ├─ Camelot
│ └─ unstructured hi_res
│ ↓
│ 헤더·결측값·숫자 정제
│ ↓
│ Markdown 표
│
└─ 이미지 추출
↓
이미지 파일 저장
↓
Base64 data URI → Vision LLM
↓
OCR 텍스트·요약 → LangChain Document
이번 실습은 OCR 결과를 Document로 변환하는 단계까지 진행한다. 이후 청킹·임베딩·검색에 연결할 수 있다.
아래 예제는 필기 노트북의 실습 순서를 따른다. 실행 경로, 이미지 저장 이름, data URI 구분자, 반환문은 실행 가능한 형태로 보완했다. 실행 결과를 인용한 부분은 필기에 저장된 출력 기준이다.
2. 환경 설정과 실습 파일
패키지 설치
터미널에서 실행하는 명령이다. 노트북 셀에서는 pip 대신 %pip를 사용한다.
1
2
3
pip install "unstructured[pdf,docx]" pymupdf "camelot-py[base]" Pillow matplotlib
pip install langchain langchain-community langchain-openai langchain-text-splitters
pip install faiss-cpu pandas pydantic python-dotenv
기본 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import base64
from pathlib import Path
import fitz # PyMuPDF
import pandas as pd
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
load_dotenv()
llm_vision = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=600)
# 2026_05_22.ipynb가 있는 폴더를 작업 디렉토리로 사용한다.
SAMPLE_DIR = Path(".")
PDF_BUSINESS = SAMPLE_DIR / "business_report.pdf"
PDF_BENCH = SAMPLE_DIR / "benchmark_test.pdf"
DOCX_REPORT = SAMPLE_DIR / "quarterly_report.docx"
IMAGE_DIR = SAMPLE_DIR / "260522_images"
원본 필기는 samples_260522 경로를 사용하지만, 저장소에서는 PDF와 DOCX가 강의자료(필기)/2026_05/2026_05_22 폴더에 있다. 다른 위치에서 실행한다면 SAMPLE_DIR를 해당 폴더로 바꾼다.
이번 코드의 주 입력은 business_report.pdf다. benchmark_test.pdf와 quarterly_report.docx는 필기의 환경 설정에 등장하는 준비 파일이다.
3. PyMuPDF로 페이지와 텍스트 블록 확인
1
2
3
4
5
6
7
8
9
10
11
with fitz.open(PDF_BUSINESS) as doc:
page = doc[0]
print("페이지 수:", len(doc))
print("페이지 크기:", page.rect.width, page.rect.height)
blocks = page.get_text("blocks")
print("텍스트 블록 수:", len(blocks))
for block in blocks[:5]:
x0, y0, x1, y1, text = block[:5]
print(f"({x0:.1f}, {y0:.1f}) - ({x1:.1f}, {y1:.1f}) | {text[:10]}")
필기에 저장된 결과:
1
2
3
페이지 수: 2
첫 페이지 크기: 약 595.28 × 841.89
첫 페이지 텍스트 블록 수: 19
x0,y0: 블록 경계의 왼쪽 위 좌표x1,y1: 블록 경계의 오른쪽 아래 좌표- 블록 너비는
x1 - x0, 높이는y1 - y0다. x1,y1을 너비·높이로 생각해서x0,y0를 다시 더하면 좌표가 잘못된다.
4. PyMuPDF 표 추출과 analyze_tables
모든 페이지에서 표 모으기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
all_tables = []
with fitz.open(PDF_BUSINESS) as doc:
for page_idx, page in enumerate(doc):
tables = page.find_tables()
for table_idx, table in enumerate(tables.tables):
df = table.to_pandas()
all_tables.append({
"page": page_idx + 1,
"shape": df.shape,
"df": df,
})
for table in all_tables:
print(f"Page {table['page']} | shape = {table['shape']}")
print(table["df"].head(3).to_string())
필기에서는 1페이지에서 (5, 4), 2페이지에서 (4, 4) 크기의 표를 얻었다. 여기서 행 수는 DataFrame의 데이터 행 기준이며 컬럼명은 포함하지 않는다.
표 구조 분석 함수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def analyze_tables(pdf_path) -> dict:
tables_info = []
total_cells = 0
with fitz.open(pdf_path) as doc:
for page_idx, page in enumerate(doc):
for table in page.find_tables().tables:
df = table.to_pandas()
tables_info.append({
"page": page_idx + 1,
"rows": df.shape[0],
"cols": df.shape[1],
"columns": list(df.columns),
})
total_cells += df.shape[0] * df.shape[1]
return {
"n_tables": len(tables_info),
"tables": tables_info,
"total_cells": total_cells,
}
table_info = analyze_tables(PDF_BUSINESS)
print(table_info)
1
2
3
4
5
필기 결과:
n_tables = 2
표 1: 5행 × 4열
표 2: 4행 × 4열
total_cells = 5 × 4 + 4 × 4 = 36
5. Camelot으로 표 추출 결과 비교
같은 PDF를 lattice 방식으로 읽고 표 개수·크기·추출 결과를 비교한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import camelot
cam_tables = camelot.read_pdf(
str(PDF_BUSINESS),
pages="all",
flavor="lattice",
)
for i, table in enumerate(cam_tables, 1):
print(
f"Table {i} | page = {table.page} | "
f"shape = {table.df.shape} | accuracy = {table.accuracy}"
)
print(table.df.head(2).to_string())
필기의 비교 결과:
| 추출 결과 | 페이지 | 크기 | accuracy | 내용 |
|---|---|---|---|---|
| 표 1 | 1 | 5 × 10 | 0 | 출력한 앞부분이 빈 문자열 |
| 표 2 | 1 | 6 × 4 | 약 100 | 사업부별 실적, 첫 행에 헤더 포함 |
| 표 3 | 2 | 5 × 4 | 100 | 주요 지표, 첫 행에 헤더 포함 |
표 개수가 많다고 유효한 표가 더 많이 추출됐다고 판단하면 안 된다. 실제 셀 내용과 헤더 위치를 함께 확인한다. accuracy도 원본 수치를 직접 대조하는 작업을 대신하지 않는다.
6. unstructured hi_res로 Table 요소 추출
1
2
3
4
5
6
7
8
9
10
11
12
from unstructured.partition.pdf import partition_pdf
elements = partition_pdf(
filename=str(PDF_BUSINESS),
strategy="hi_res",
)
unst_tables = [element for element in elements if element.category == "Table"]
for table in unst_tables:
print("page =", table.metadata.page_number)
print(str(table)[:50])
필기에서는 1페이지의 사업부별 실적, 2페이지의 주요 지표가 Table 요소로 출력됐다.
| 방식 | 이번 실습에서 확인한 결과 | 확인할 점 |
|---|---|---|
| PyMuPDF | 표를 DataFrame으로 변환 | 컬럼명과 데이터 행이 올바른가 |
| Camelot lattice | 선 기반 표 추출 결과 비교 | 빈 표, 헤더 행, 셀 분리가 올바른가 |
| unstructured hi_res | Table 요소와 페이지 정보 | 표가 Table로 분류됐는가, 텍스트가 보존됐는가 |
str(table)은 표 요소의 텍스트 표현이다. 이 코드만으로 DataFrame의 행·열 구조까지 얻었다고 보지는 않는다.
7. clean_table — 헤더·결측값·숫자 정제
정제 함수의 기본값은 입력 파서가 이미 수행한 작업과 맞아야 한다. 헤더 승격이나 숫자 변환을 무조건 적용하기보다 변환 전후의 열 이름과 첫 데이터 행을 비교하고, 단위가 사라지는 변환은 별도 규칙으로 둔다.
원본 표 확인
1
2
3
4
with fitz.open(PDF_BUSINESS) as doc:
raw_df = doc[0].find_tables().tables[0].to_pandas()
print(raw_df.to_string())
정제 순서:
- 첫 행이 실제 헤더인 경우에만 컬럼명으로 올린다.
N/A,-, 빈 문자열을 결측값으로 통일한다.- 숫자 형태가 많은 열은 쉼표·통화 기호·퍼센트 기호를 제거하고 숫자로 변환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def clean_table(df: pd.DataFrame, promote_first_row: bool = False) -> pd.DataFrame:
df = df.copy()
# Camelot 결과처럼 헤더가 데이터의 첫 행에 있을 때만 사용한다.
if promote_first_row and not df.empty:
df.columns = df.iloc[0].astype(str).tolist()
df = df.iloc[1:].reset_index(drop=True)
df = df.replace({"N/A": None, "-": None, "": None})
for column in df.columns:
sample = df[column].dropna().astype(str).str.strip()
if sample.empty:
continue
numeric_ratio = sample.str.fullmatch(r"[\$\+\-]?[\d,]+\.?\d*%?").mean()
if numeric_ratio > 0.6:
cleaned = (
df[column].astype("string").str.strip()
.str.replace(r"[\$,%]", "", regex=True)
)
df[column] = pd.to_numeric(cleaned, errors="coerce")
return df
clean_df = clean_table(raw_df)
print(clean_df.to_string())
- PyMuPDF에서 이미 컬럼명을 얻은
raw_df에는 기본값인promote_first_row=False를 사용한다. - Camelot 결과의 첫 행이 헤더임을 확인했다면
clean_table(table.df, promote_first_row=True)로 처리한다. +42%는 이 코드에서42로 바뀐다. 비율 계산용0.42가 필요하면 해당 열을 추가로 100으로 나눈다.85만처럼 한글 단위가 들어 있는 값은 이 정규식에서 숫자로 분류하지 않는다.
8. table_to_markdown — 표를 LLM 입력으로 변환
LLM에 표의 열 이름과 행 관계를 전달하기 위해 Markdown 문자열로 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def table_to_markdown(df: pd.DataFrame, max_rows: int = 10) -> str:
df = df.astype(object).where(pd.notna(df), "")
def cell_text(value):
return str(value).replace("|", "\\|").replace("\n", "<br>")
header = "| " + " | ".join(cell_text(c) for c in df.columns) + " |"
separator = "| " + " | ".join(["---"] * len(df.columns)) + " |"
rows = [
"| " + " | ".join(cell_text(value) for value in row) + " |"
for row in df.head(max_rows).itertuples(index=False, name=None)
]
result = "\n".join([header, separator] + rows)
if len(df) > max_rows:
result += f"\n\n... ({len(df) - max_rows} more rows)"
return result
md_text = table_to_markdown(clean_df)
print(md_text)
헤더를 유지했을 때의 출력 형태:
1
2
3
4
5
6
7
| 사업부 | 매출(억) | 영업이익(억) | 전년동기 대비 |
| --- | --- | --- | --- |
| AI 솔루션 | 98 | 16 | 42 |
| 클라우드 | 54 | 6 | 18 |
| 컨설팅 | 24 | 1 | 5 |
| 기타 | 12 | 1 | -2 |
| 합계 | 188 | 24 | 35 |
전년동기 대비 열의 값은 퍼센트 단위다. LLM에 넘길 때 단위를 함께 설명하거나 컬럼명에 (%)를 추가한다. max_rows를 넘는 행은 생략되므로 전체 표가 필요한 작업에서는 제한을 조정한다.
9. PDF 이미지 추출과 파일 저장
get_images(full=True)로 이미지 참조를 찾고, extract_image(xref)로 이미지 바이트를 얻는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
saved = []
with fitz.open(PDF_BUSINESS) as doc:
for page_idx, page in enumerate(doc):
for image_idx, image in enumerate(page.get_images(full=True)):
xref = image[0]
extracted = doc.extract_image(xref)
extension = extracted["ext"]
image_path = IMAGE_DIR / f"p{page_idx + 1}_img{image_idx + 1}.{extension}"
image_path.write_bytes(extracted["image"])
saved.append(str(image_path))
print(saved)
- 페이지 번호와 이미지 번호를 이름에 넣어 서로 다른 이미지의 덮어쓰기를 방지한다.
saved를 다음 OCR 단계의 입력으로 사용한다.- 이 과정은 PDF에 포함된 이미지 추출이다. 페이지 전체를 이미지로 렌더링하는 과정과는 구분한다.
10. analyze_image — Vision LLM으로 OCR과 요약
이미지 파일을 Base64로 인코딩하고, 텍스트 요청과 함께 모델에 전달한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def analyze_image(
image_path,
prompt="이 이미지에 보이는 모든 텍스트를 추출하고 한 문장으로 요약해줘",
):
image_path = Path(image_path)
encoded = base64.b64encode(image_path.read_bytes()).decode("utf-8")
extension = image_path.suffix.lstrip(".").lower()
if extension == "jpg":
extension = "jpeg"
message = HumanMessage(content=[
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/{extension};base64,{encoded}"},
},
])
return llm_vision.invoke([message]).content
imgs = saved
if imgs:
print(analyze_image(imgs[0]))
else:
print("추출된 이미지가 없습니다.")
필기의 첫 이미지 OCR 결과에는 다음 값이 포함돼 있었다.
1
2
3
4
5
2025 Quarterly Revenue (억 원)
Q1: 120
Q2: 145
Q3: 162
Q4: 188
이 값은 필기에 저장된 모델 응답이다. 새로 호출한 응답과 문구가 같다고 가정하지 않고, 차트 수치는 원본 이미지와 대조한다.
11. VisionOCR — 여러 이미지 처리와 Document 변환
여러 이미지를 처리할 때는 성공 개수뿐 아니라 어떤 입력이 실패했는지 복원할 수 있어야 한다. 실패 항목을 남기는 구조를 바탕으로 재실행 대상을 고르면 이미 처리한 파일의 중복 호출과 결과 덮어쓰기를 줄일 수 있다.
이미지별 OCR 결과와 오류를 저장하고, 텍스트가 있는 성공 결과만 Document로 변환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from langchain_core.documents import Document
class VisionOCR:
def __init__(self, llm):
self.llm = llm
self.results = []
def process(self, paths: list, prompt: str = "이미지에서 모든 내용을 추출하세요") -> list:
self.results = []
for path in paths:
path = Path(path)
try:
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
extension = path.suffix.lstrip(".").lower()
if extension == "jpg":
extension = "jpeg"
message = HumanMessage(content=[
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/{extension};base64,{encoded}"},
},
])
text = self.llm.invoke([message]).content
self.results.append({"path": str(path), "text": text, "error": None})
except Exception as error:
self.results.append({"path": str(path), "text": "", "error": str(error)})
return self.results
def to_documents(self) -> list:
return [
Document(page_content=result["text"], metadata={"path": result["path"]})
for result in self.results
if result["error"] is None and result["text"]
]
ocr = VisionOCR(llm_vision)
results = ocr.process(imgs[:2])
docs = ocr.to_documents()
for result in results:
print(result["path"], result["error"])
for doc in docs:
print(doc.metadata, doc.page_content[:100])
1
2
3
4
5
6
7
8
이미지 경로 목록
↓ process()
path / text / error 기록
↓ to_documents()
Document(
page_content=OCR 결과,
metadata={이미지 파일 경로}
)
원본 필기에는 이미지 2개를 처리한 결과와 Document 2개가 저장돼 있다. 실패한 파일은 results의 error로 확인하고, 성공한 문서의 metadata로 출처 이미지를 찾을 수 있다.
12. 핵심 개념 총정리
| 개념 | 역할 |
|---|---|
| fitz.open | PDF 열기 |
| get_text(“blocks”) | 텍스트와 경계 좌표 확인 |
| find_tables / to_pandas | PDF 표를 DataFrame으로 변환 |
| analyze_tables | 표 개수·행·열·셀 수 요약 |
| Camelot lattice | 같은 PDF의 표 추출 결과 비교 |
| partition_pdf hi_res | Table 요소와 페이지 정보 추출 |
| clean_table | 헤더·결측값·숫자 정제 |
| table_to_markdown | 표를 LLM 입력용 문자열로 변환 |
| get_images / extract_image | PDF 내 이미지 추출 |
| Base64 data URI | 이미지 바이트를 모델 메시지에 전달 |
| VisionOCR | 여러 이미지의 OCR 결과와 오류 관리 |
| Document | OCR 텍스트와 출처 메타데이터를 함께 보관 |
13. 자주 나오는 실수/주의사항
실수 1: 첫 데이터 행을 헤더로 덮어쓰기
PyMuPDF가 이미 헤더를 인식했다면 df.iloc[0]은 첫 데이터다. 필기의 저장된 Markdown 출력에는 AI 솔루션 / 98 / 16 / +42%가 헤더로 올라간 결과가 남아 있다. 위 정리 코드에서는 헤더 승격을 명시적으로 선택하게 했다.
실수 2: 숫자 변환 중 결측값에서 오류 발생
N/A, -, 빈 문자열을 처리한 뒤 pd.to_numeric(..., errors="coerce")로 변환한다. 변환하지 못한 값은 결측값이 되므로 결과를 확인한다. %를 제거하는 것과 백분율을 소수로 바꾸는 것도 구분한다.
실수 3: 이미지 저장 경로와 파일 이름 오류
원본의 os.path.join(f"'./260522_images', 'abc.{ext}'")는 폴더와 파일 이름을 나누어 전달한 형태가 아니다. 위 코드처럼 폴더를 생성한 뒤 IMAGE_DIR / 파일이름으로 경로를 만들고, 이미지마다 고유한 이름을 사용한다.
실수 4: data URI 구분자 오타
1
2
잘못된 형식: data:image/png:base64,...
올바른 형식: data:image/png;base64,...
원본 필기의 :base64를 정리 예제에서는 ;base64로 보완했다. .jpg 파일의 MIME 유형에는 image/jpeg를 사용한다.
실수 5: process()의 반환문 누락
타입 힌트가 -> list여도 실제 return이 없으면 반환값은 None이다. 원본은 ocr.results에서 결과를 읽지만, res = ocr.process(...)에도 결과가 담기도록 정리 예제에 return self.results를 추가했다.
실수 6: 추출 개수만 보고 성공 판단
필기의 Camelot 결과처럼 빈 표가 추가로 잡힐 수 있다. 표는 헤더와 셀 값을, OCR은 이미지의 수치와 단위를 확인한다. 노트북에서 코드를 고친 뒤 셀을 다시 실행하지 않으면 현재 코드와 저장된 출력이 다를 수도 있다.
14. 추출 결과를 채택하는 기준
이번 실습의 Camelot 출력처럼 빈 표가 추가로 검출될 수 있으므로 결과 개수를 품질 점수로 사용하지 않는다. 원문의 헤더와 셀 값을 기준으로 비교하고, 오류 유형에 따라 파서 선택과 후처리 규칙을 나누어 검토한다.
LLM에 넘길 결과에는 수치의 단위와 원본 위치를 남긴다. 표를 짧게 만들거나 OCR을 자연스러운 문장으로 바꾸는 과정에서도 원본의 어떤 정보를 생략했는지 확인할 수 있어야 이후 답변 오류를 추적할 수 있다.