Image: AI generated
pip install markitdown만으로 PDF·워드·엑셀·파워포인트가 마크다운으로 바뀐다. 하지만 스캔한 계약서처럼 페이지 전체가 이미지인 PDF를 넣으면 빈 문서가 나온다. 이 글은 두 축으로 이어진다 — MarkItDown을 왜, 어떻게 쓰는가, 그리고 그 신뢰가 끊기는 지점(이미지 PDF)을 OCR로 어떻게 메우는가.
MarkItDown을 왜 써야 하는가 — Agent Operable이라는 조건
MarkItDown은 마이크로소프트가 공개한 오픈소스 CLI/Python 라이브러리로, PDF·Word·Excel·PowerPoint·HTML·이미지·오디오 등 다양한 파일을 마크다운으로 변환한다.
Agent Operable(에이전트가 다룰 수 있는 상태) 이라는 개념부터 짚는다. 에이전트가 문서를 자율적으로 다루려면, 그 문서가 사람이 읽기 좋은 상태가 아니라 기계가 구조적으로 파싱 가능한 상태여야 한다. 이건 코드에만 해당하는 조건이 아니다 — PDF·Excel 안에 묻힌 비즈니스 규칙은 사람이 열어봐야 알 수 있는 이상 에이전트에게는 존재하지 않는 것과 같다. Building Agent-Operable Systems에서 정리한 세 조건(읽기 가능·검증 가능·영속) 중 첫 번째, “읽기 가능 — 노이즈 없이"가 여기 해당한다.
그런데 왜 굳이 MarkItDown인가? 서드파티 파서(PyPDF2, python-docx를 직접 조합)로도 같은 변환은 만들 수 있다. 차이는 누가 만들었는가에 있다. docx·xlsx·pptx는 마이크로소프트 자신이 소유한 OOXML 스펙이다. 스펙을 소유한 회사가 자사 포맷을 자사 도구로 변환하면, 병합 셀·중첩 표·각주 같은 엣지케이스에서 서드파티 파서보다 구조적으로 더 신뢰할 수 있다. 문서를 agent-operable 상태로 바꾸는 첫 단계에서, 변환 자체의 신뢰성이 흔들리면 그 위에 쌓는 모든 것이 무의미해진다. MarkItDown을 쓰는 이유는 여기에 있다 — MS 자사 포맷에 대해서는 이 신뢰가 기본으로 확보된다.
문제는 이 신뢰가 PDF, 특히 이미지로만 된 PDF에서는 그대로 이어지지 않는다는 것이다. 이 글의 후반부는 그 지점을 메우는 OCR 세팅에 집중한다.
설치
기본 설치
pip install markitdown
기본 설치만으로는 텍스트·HTML 등 가벼운 포맷만 다룰 수 있다. PDF·Office 문서를 변환하려면 포맷별 확장(extra)을 함께 설치해야 한다.
포맷별 확장 설치
pip install 'markitdown[pdf]' # PDF (pdfminer.six + pdfplumber)
pip install 'markitdown[docx]' # Word
pip install 'markitdown[pptx]' # PowerPoint
pip install 'markitdown[xlsx]' # Excel
pip install 'markitdown[all]' # everything above, plus audio transcription, YouTube captions, Azure Document Intelligence, etc.
이 글에서 다루는 PDF만 필요하다면 markitdown[pdf]로 충분하다.
기본 사용법
CLI
markitdown example.pdf # print markdown to stdout
markitdown example.pdf -o example.md # save to a file
cat example.pdf | markitdown # read from stdin (pipe)
Python API
from markitdown import MarkItDown
md = MarkItDown()
result = md.convert("example.pdf")
print(result.markdown)
두 방식 모두 확장자나 MIME 타입으로 컨버터를 자동 선택한다. 표준입력처럼 확장자 정보가 없는 경우 -x(--extension) 또는 -m(--mime-type) 플래그로 힌트를 줄 수 있다.
PDF 변환의 실제 동작 — 텍스트 레이어만 읽는다
MarkItDown의 PDF 컨버터(packages/markitdown/src/markitdown/converters/_pdf_converter.py)는 pdfplumber로 각 페이지의 표·폼 구조를 먼저 감지하고, 표가 아니면 page.extract_text()로 텍스트를 뽑는다. pdfplumber가 실패하면 pdfminer.six로 폴백한다.
두 라이브러리 모두 PDF 내부에 이미 존재하는 텍스트 레이어를 읽는 도구다. 페이지가 스캔된 이미지 한 장으로만 구성돼 있고 텍스트 레이어가 없다면, extract_text()는 빈 문자열을 반환하고 MarkItDown의 결과물도 사실상 비어 있게 된다. 이미지를 렌더링하거나 문자를 인식하는 코드 경로 자체가 기본 패키지에는 없다.
즉, 다음 두 가지는 서로 다른 문제다.
- 텍스트 레이어가 있는 PDF(워드에서 내보낸 PDF, 대부분의 전자 계약서 등) → 기본 설치만으로 잘 동작한다. MS 자사 포맷 변환과 같은 수준의 신뢰가 이어진다.
- 텍스트 레이어가 없는 PDF(스캐너로 찍은 문서, 사진을 이미지로 삽입한 PDF) → 기본 설치로는 빈 결과가 나온다. Agent Operable의 “읽기 가능” 조건이 여기서 끊긴다.
OCR 세팅법 — 끊긴 신뢰를 메우는 지점
여기서부터가 이 글의 두 번째 축이다. MarkItDown 자체가 자사 포맷에서 확보하는 신뢰를, 이미지 PDF에서는 별도로 만들어 붙여야 한다.
1. 플러그인 설치
OCR은 별도 패키지 markitdown-ocr로 분리돼 있다. 같은 저장소(microsoft/markitdown) 안에 있지만 pip install markitdown에는 포함되지 않는다.
pip install markitdown-ocr
pip install openai # an OpenAI-compatible client — this plugin has no OCR model of its own
설치 후 플러그인이 인식되는지 확인한다.
markitdown --list-plugins
# * ocr (package: markitdown_ocr)
2. 반드시 필요한 것 — LLM Vision 클라이언트
markitdown-ocr은 Tesseract, PaddleOCR 같은 전통적 OCR 엔진을 내장하지 않는다. 실제로 하는 일은 이미지를 base64로 인코딩해 OpenAI 호환 chat.completions API(예: gpt-4o)에 “이 이미지에서 텍스트를 추출해줘"라고 요청하는 것뿐이다(_ocr_service.py의 LLMVisionOCRService). 따라서 비전 모델을 호출할 수 있는 API 키가 반드시 있어야 OCR이 실제로 동작한다.
from markitdown import MarkItDown
from openai import OpenAI
md = MarkItDown(
enable_plugins=True,
llm_client=OpenAI(), # reads OPENAI_API_KEY from the environment
llm_model="gpt-4o",
)
result = md.convert("scanned_contract.pdf")
print(result.markdown)
Azure OpenAI 등 OpenAI 호환 클라이언트라면 무엇이든 그대로 넘길 수 있다.
from openai import AzureOpenAI
md = MarkItDown(
enable_plugins=True,
llm_client=AzureOpenAI(
api_key="...",
azure_endpoint="https://<resource>.openai.azure.com/",
api_version="2024-02-01",
),
llm_model="gpt-4o",
)
3. CLI로는 안 된다 — 실제 코드로 확인한 사실
markitdown-ocr의 README는 다음 명령을 예시로 들고 있다.
markitdown document.pdf --use-plugins --llm-client openai --llm-model gpt-4o
하지만 MarkItDown 코어의 CLI 인자 정의(packages/markitdown/src/markitdown/__main__.py)를 직접 확인한 결과, --llm-client나 --llm-model 플래그는 존재하지 않는다. CLI가 지원하는 옵션은 -o(출력 파일), -x(확장자 힌트), -m(MIME 힌트), -c(charset), -d/--use-docintel, --use-cu, -p/--use-plugins, --list-plugins, --keep-data-uris뿐이고, MarkItDown 생성자에는 enable_plugins=args.use_plugins만 전달된다. 즉 README의 CLI 예시는 실제로는 동작하지 않는 문서 오류다.
OCR을 쓰려면 반드시 Python API로 llm_client/llm_model을 직접 넘겨야 한다. CLI만으로는 API 키를 전달할 방법이 없다.
4. 동작 원리 — 임베디드 이미지 OCR과 전체 페이지 OCR 폴백
PdfConverterWithOCR(_pdf_converter_with_ocr.py)은 두 단계로 동작한다.
- 임베디드 이미지 우선 처리: 페이지 안에 텍스트와 그림이 섞여 있으면(예: 본문 중간에 스캔된 서명 이미지), 이미지만 잘라내 LLM Vision에 보내고 그 결과를 원래 위치(Y좌표) 기준으로 주변 텍스트와 인터리빙해 순서를 보존한다.
- 전체 페이지 OCR 폴백: 텍스트 추출 결과가 통째로 비어 있으면(스캔본 PDF 전체), 페이지를 300dpi PNG로 렌더링해 LLM Vision에 한 페이지씩 통째로 보낸다(
_ocr_full_pages).
추출된 텍스트는 항상 다음 형식으로 감싸져 출력된다.
*[Image OCR]
<extracted text>
[End OCR]*
5. 커스텀 프롬프트로 정확도 올리기
기본 프롬프트는 “이 이미지에서 모든 텍스트를 추출해줘, 레이아웃과 순서를 유지해줘"다. 표가 많은 문서, 세로쓰기 문서, 도장·서명이 섞인 문서라면 llm_prompt로 직접 프롬프트를 덮어써서 정확도를 올릴 수 있다.
md = MarkItDown(
enable_plugins=True,
llm_client=OpenAI(),
llm_model="gpt-4o",
llm_prompt="Extract all text from this image, but preserve table structure as markdown tables.",
)
6. 여러 파일 일괄 처리
스캔본 PDF 폴더 전체를 한 번에 돌리는 경우, 파일 하나하나에 API 호출이 걸리므로 실패 시 전체가 중단되지 않도록 개별적으로 예외를 잡아야 한다.
from pathlib import Path
from markitdown import MarkItDown
from openai import OpenAI
md = MarkItDown(enable_plugins=True, llm_client=OpenAI(), llm_model="gpt-4o")
for pdf_path in Path("scans/").glob("*.pdf"):
try:
result = md.convert(str(pdf_path))
pdf_path.with_suffix(".md").write_text(result.markdown, encoding="utf-8")
except Exception as e:
print(f"failed: {pdf_path.name} — {e}")
7. llm_client 없이 설치만 하면?
플러그인은 로드되지만 OCR은 조용히 꺼진다. _plugin.py의 register_converters()는 llm_client와 llm_model이 둘 다 있을 때만 LLMVisionOCRService를 만들고, 없으면 ocr_service=None으로 컨버터를 등록한다. 이 상태에서는 텍스트 레이어만 추출하는 기본 동작으로 자동 폴백되고 에러도, 경고도 뜨지 않는다. “플러그인을 설치했는데 OCR이 안 된다"는 문제의 대부분은 이 지점에서 발생한다.
8. 손상된 PDF도 복구를 시도한다
pdfplumber나 pdfminer가 아예 열지 못하는 손상된 PDF(예: EOF가 잘린 파일)를 만나면, markitdown-ocr은 PyMuPDF(fitz)로 페이지를 직접 렌더링해 재시도한다. 이 경로까지 실패하면 *[Error: Could not process scanned PDF]*를 결과에 남긴다.
9. 비용과 속도
전체 페이지를 비전 모델에 보내는 방식이므로, 많은 페이지의 스캔본을 처리하면 비전 모델 API 호출 비용과 지연이 페이지 수에 비례해 누적된다. 300dpi 렌더링은 이미지 한 장당 토큰 소모가 적지 않으므로, 수백 페이지짜리 문서를 통째로 돌리기 전에 표본 페이지 몇 장으로 먼저 품질과 비용을 가늠하는 편이 안전하다.
OCR 결과 검증 (Verifier) — 별도 예정
지금까지의 세팅은 OCR을 “켜는” 방법이다. 하지만 켜졌다고 해서 그 결과를 믿을 수 있는 건 아니다 — llm_client 없이도, 빈 페이지여도 에러 없이 통과되는 지금 구조 위에서는, OCR 출력이 실제로 신뢰할 만한지 판정하는 결정론적 절차가 없다.
이 절차를 여기서 다루지 않는다. LLM 생성 → 결정론적 도구 판정 → 피드백 → 반복이라는 Symbolic Feedback Loop 원칙을 OCR 출력에 어떻게 적용할지는 별도의 글/도구로 만든다.
실전 체크리스트
- 텍스트 레이어가 있는 일반 PDF만 다룬다면
markitdown[pdf]기본 설치로 끝난다. OCR 플러그인은 불필요하다. - 스캔본·사진 PDF를 다뤄야 한다면
markitdown-ocr+ OpenAI(또는 호환) API 키가 필수다. 로컬에서 무료로 도는 전통 OCR 엔진(Tesseract 등) 통합은 없다. - OCR 결과가 비어 있다면 가장 먼저 의심할 것은
llm_client/llm_model을 실제로 넘겼는지다. 조용히 스킵되기 때문에 로그로는 드러나지 않는다. - CLI만으로는 OCR을 켤 수 없다. Python 스크립트로
MarkItDown(enable_plugins=True, llm_client=..., llm_model=...)를 호출해야 한다. - 결과의 신뢰도를 기계적으로 판정하는 verifier는 아직 없다 — 지금은 사람이 표본 검수해야 한다.
요약 표
| 상황 | 필요한 것 | 비고 |
|---|---|---|
| 텍스트 레이어 있는 PDF | pip install markitdown[pdf] | pdfplumber/pdfminer가 텍스트 추출 |
| 임베디드 이미지 일부 OCR | markitdown-ocr + LLM Vision 클라이언트 | 이미지만 잘라 OCR, 텍스트와 인터리빙 |
| 스캔본(페이지 전체 이미지) | markitdown-ocr + LLM Vision 클라이언트 | 페이지 전체를 300dpi로 렌더링 후 OCR |
| 손상된 PDF | 위와 동일 (자동 폴백) | PyMuPDF로 렌더링 재시도 |
llm_client 미지정 | — | 조용히 OCR 스킵, 에러 없음 |
CLI에서 --llm-client 지정 | 불가능 (실제 플래그 없음) | Python API로만 가능 |
| OCR 결과 신뢰도 판정 | (예정) | 별도 verifier 글/도구에서 다룸 |
관련 글
- Building Agent-Operable Systems — 읽기 가능·검증 가능·영속이라는 세 조건, 그리고 MarkItDown이 채우는 자리
- Agent Operable Codebase — 같은 원칙을 코드에 적용한 사례
출처
- microsoft/markitdown — 저장소 루트
_pdf_converter.py— 기본 PDF 컨버터 (텍스트 레이어 추출만)__main__.py— CLI 인자 정의 (--llm-client/--llm-model부재 확인)markitdown-ocrREADME_pdf_converter_with_ocr.py_ocr_service.py_plugin.py- markitdown pyproject.toml — extras 목록
변경이력
- 2026-07-09: 초판 — Agent Operable 프레이밍 추가, OCR 세팅을 후반부 중심으로 확장, verifier 자리 표시 섹션 추가