
오늘은 로컬 LLM(Ollama의 Gemma 3)을 사용하여 PDF를 깔끔하고 구조화된 마크다운으로 변환하는 방법을 보여드리겠습니다 .
클라우드 API도, 개인정보 보호 걱정도, 자정에 사라지는 API 토큰도 없습니다. Python과 약간의 힘, 그리고 우아함만 있으면 됩니다.
단계별로 자세히 살펴보겠습니다.
[ 깃허브 ]
우리가 하는 일
아이디어는 간단합니다.
1. 각 PDF 페이지를 이미지로 변환합니다.
2. Ollama를 사용하여 해당 이미지를 로컬 LLM 으로 보냅니다 . 구체적으로는 gemma3:12b입니다(gemma3:4b도 사용 가능).
3. 모델에게 마크다운 형식으로 읽을 수 있는 콘텐츠를 추출하도록 요청합니다.
4. 실제로 사용할 수 있는 .md 파일로 결과를 저장합니다.
자, 이제 반전이 있습니다. 이미지 입력을 사용하기 때문에 스캔한 PDF 에서도 작동합니다 . OCR, 레이아웃 감지, 서식 지정 기능을 모두 하나로 통합한 것이죠.
우리가 사용하는 도구
- PyMuPDF(일명 fitz): PDF 페이지를 이미지로 렌더링합니다.
- Pillow: 이미지를 PNG 바이트로 변환하고 저장합니다.
- ollama: 지역 모델과 채팅합니다(OpenAI 키가 필요 없습니다!)
- gemma3:12b(또는 gemma3:4b): 개인 정보를 존중하는 강력한 모델입니다.
필요한 모든 것을 설치하세요:
# macOS 또는 Linux에서 최신 UV 설치
# curl을 사용하여:
curl -LsSf https://astral.sh/uv/install.sh | sh
# 또는 wget을 사용하여:
wget -qO- https://astral.sh/uv/install.sh | sh
# 또는 Windows에서:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
# UV 환경 생성
uv init pdftomd
cd pdftomd
# 필요한 패키지를 환경에 추가:
uv pip install pymupdf pillow ollama
Ollama가 설치되어 있는지 확인하세요(그렇지 않은 경우 https://ollama.com/download 의 지침을 따르세요 ) 그리고 로컬에서 실행되고 gemma3가 풀되었는지 확인하세요:
ollama run gemma3:12b
# 또는 너무 많은 리소스를 사용하고 싶지 않다면:
ollama run gemma3:4b
코드
정의된 환경을 사용하는 Python 셸에서 코드를 실행하려면 uv다음 중 하나를 수행합니다.
# 대화형
uv run python # 또는 ipython을 설치한 경우 uv run ipython
나중에 실행 스크립트가 있는 경우 다음을 수행할 수 있습니다.
# pdftomd 폴더 내부에서:
uv run myscript.py
코드 세션을 시작해 보겠습니다.
import fitz # PyMuPDF for PDF
import ollama
import io
from PIL import Image
필수 요소를 가져옵니다. PyMuPDF(fitz 제공)는 페이지 렌더링에 유용하고, Pillow는 원시 데이터를 적절한 PNG로 변환하는 데 도움이 됩니다.
1단계: PDF 페이지를 이미지로 변환
def convert_pdf_to_images(pdf_path):
images = []
doc = fitz.open(pdf_path) # Open the PDF
for page_num in range(len(doc)):
pix = doc[page_num].get_pixmap() # Render page to pixel map
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) # Convert to PIL image
img_buffer = io.BytesIO()
img.save(img_buffer, format="PNG") # Save as in-memory PNG
images.append(img_buffer.getvalue()) # Raw PNG bytes
return images
이 기능은 PDF를 읽고, 각 페이지를 고해상도 이미지로 변환하고, 원시 PNG 바이트로 메모리에 저장합니다. 이는 이미지를 허용하는 LLM으로 보내기에 적합합니다.
원시 바이트를 사용하는 이유는 Ollama가 원시 바이트를 직접 지원하기 때문입니다. 디스크에 파일을 쓸 필요가 없어 더 빠르고 깔끔합니다.
2단계: LLM에 텍스트 추출 요청
prompt = "이 이미지에서 읽을 수 있는 모든 텍스트를 추출하여 구조화된 마크다운으로 포맷합니다."
def query_llm_with_images ( image_bytes_list, model= "gemma3:12b" , prompt=prompt ):
response = ollama.chat(
model=model,
messages=[{
"role" : "user" ,
"content" : prompt,
"images" : image_bytes_list
}]
)
return response[ "message" ][ "content" ]
바로 여기서 마법이 일어납니다. 로컬 Gemma 모델 에 이미지 데이터를 전송하여 복잡한 작업을 처리하도록 요청하는 것이죠.
보너스 : 모든 것이 로컬에서 실행되므로 데이터가 컴퓨터 밖으로 유출되지 않습니다 . 민감한 문서 보관에 유용합니다.
3단계: 모두 합치기
pdf_path = "mypdf.pdf" # PDF 파일로 바꾸기
images = convert_pdf_to_images(pdf_path)
if images:
print ( f" { len (images)} 페이지를 이미지로 변환했습니다." )
extract_text = query_llm_with_images(images)
with open ( "output.md" , "w" , encoding= "utf-8" ) as md_file:
md_file.write(extracted_text)
print ( "\n마크다운 변환이 완료되었습니다! `output.md`를 확인하세요." )
else :
print ( "PDF에서 이미지를 찾을 수 없습니다." )
이 마지막 섹션에서는 모든 내용을 하나로 묶습니다.
- 귀하의 PDF를 로드합니다.
- 이미지로 변환합니다.
- 모델에 공급합니다.
- 결과를 output.md. 에 저장합니다.
모두 한번에.
당신이 얻는 것
- ✅ LLM 파이프라인, 지식 기반 또는 사람이 읽기에 적합한 마크다운 지원 출력 입니다.
- ✅ 이미지 기반 접근 방식 덕분에 스캔한 PDF와 호환됩니다 .
- ✅ 모든 추론은 로컬에서 실행되므로 기본적으로 비공개입니다 .
- ✅ 우아한 단순함 - 복잡한 OCR 파이프라인이나 불안정한 PDF 파서가 없습니다.
영감을 주는 사용 사례
- 오래된 스캔된 교과서를 마크다운으로 변환하여 모델 미세 조정
- 로컬 임베딩을 사용하여 오프라인 문서 QA 시스템 구축
- 변환된 문서를 검색 기능이 강화된 챗봇으로 제공
- 회의록, 과학 논문 또는 재무 보고서를 요약합니다.
- PDF, Word 파일 등에서 마크다운 지식 기반을 만듭니다.
마지막 생각
우리는 종종 복잡성을 쫓습니다. 복잡성에 힘이 있다고 생각하기 때문입니다. 하지만 때로는 마찰을 없애고 모든 것이 자연스럽게 연결되도록 만드는 것이 중요합니다.
이 워크플로는 빠르고, 로컬에서 실행 가능하며, 스마트합니다 . 거의 모든 PDF에서 최소한의 노력으로 마크다운을 생성할 수 있으며, 클라우드 종속성 없이 몇 초 만에 실행됩니다.
전체 코드:
import fitz # PDF용 PyMuPDF
import ollama
import io
from PIL import Image
def convert_pdf_to_images ( pdf_path ):
images = []
doc = fitz.open ( pdf_path) # PDF 열기
for page_num in range ( len (doc)):
pix = doc[page_num].get_pixmap() # 페이지를 픽셀 맵으로 렌더링
img = Image.frombytes( "RGB" , [pix.width, pix.height], pix.samples) # PIL 이미지로 변환
img_buffer = io.BytesIO()
img.save(img_buffer, format = "PNG" ) # 메모리 내 PNG로 저장
images.append(img_buffer.getvalue()) # 원시 PNG 바이트
return images
prompt = "이 이미지에서 읽을 수 있는 모든 텍스트를 추출하여 구조화된 마크다운으로 포맷합니다."
def query_llm_with_images ( image_bytes_list, model= "gemma3:12b" , prompt=prompt ):
response = ollama.chat(
model=model,
messages=[{
"role" : "user" ,
"content" : prompt,
"images" : image_bytes_list
}]
)
return response[ "message" ][ "content" ]
if __name__ == '__main__' :
pdf_path = "mypdf.pdf" # PDF 파일로 교체
images = convert_pdf_to_images(pdf_path)
if images:
print ( f" { len (images)} 페이지를 이미지로 변환했습니다." )
extract_text = query_llm_with_images(images)
with open ( "output.md" , "w" , encoding= "utf-8" ) as md_file:
md_file.write(extracted_text)
print ( "\n마크다운 변환 완료! `output.md`를 확인하세요." )
else :
print ( "PDF에서 이미지를 찾을 수 없습니다." )
이 코드를 아래에 저장하면 nano pdftomd.py다음과 같이 실행할 수 있습니다.
uv run pdftomd .py
다음을 사용하여 단일 이미지에서 텍스트를 추출할 수 있습니다.
ollama import base64
import
def image_to_text ( image_path, model= "gemma3:12b" , prompt= "이 이미지에서 텍스트 추출" ):
with ( open (image_path, "rb" ) as f:
image_data = f.read()
response = ollama.chat(model=model,
messages=[{ "role" : "user" ,
"content" : prompt,
"images" : [image_data]}])
return response[ "message" ][ "content" ]
더 나은 프롬프트를 사용하면 결과를 개선할 수 있습니다.
prompt = "이 이미지에서 읽을 수 있는 모든 텍스트와 텍스트 청크를 추출합니다." + \
" 그리고 구조화된 마크다운으로 포맷합니다." + \
" 항상 전체 이미지를 살펴보고 모든 텍스트를 검색해 보세요!"
프롬프트를 변경하고 다른 형식(예: JSON)을 요청하거나 다른 측면을 요청하면 이 프로그램의 동작을 완전히 바꿀 수 있습니다. 이것이 바로 LLM 프롬프트를 사용하는 프로그램의 장점입니다. (예를 들어 "추출된 내용을 한국어로 번역해 주세요."와 같은 내용의 번역을 요청할 수도 있습니다.)
지금까지 PDF 파일이나 이미지에서 마크다운을 어떻게 추출하고 계신가요? 추출이나 PDF를 마크다운으로 변환하는 과정에서 어떤 경험을 하셨나요?
에필로그 1: PyMuPDF 대신 Poppler 사용
Medium 회원 fitz 서 패키지에 포함된 PyMuPDF는 상업적 용도로 무료가 아니라는 점을 정확히 지적해 주셨습니다 . 감사합니다,
이 글과 동일한 Julia 솔루션(아래 참조)에서는 를 사용했습니다 . Python에서도 Poppler_jll사용할 수 있습니다 .poppler
준비물:
pip install pdf2image
brew install poppler # 또는 sudo apt install poppler-utils
그리고 함수를 다음으로 바꿔보겠습니다 convert_pdf_to_image().
from pdf2image import convert_from_path
from io import BytesIO
def convert_pdf_to_images ( pdf_path ):
images = []
pil_images = convert_from_path(pdf_path, dpi= 300 ) # Poppler를 사용하여 각 페이지를 렌더링합니다.
for img in pil_images:
img_buffer = BytesIO()
img.save(img_buffer, format = "PNG" ) # PIL 이미지를 메모리의 PNG로 변환
images.append(img_buffer.getvalue()) # 원시 PNG 바이트 이미지
반환
pdf2image라이센스가 있으며
Poppler는 GPL(GNU General Public License - 상업용 제품에서 사용할 수 있지만 앱에 정적으로 링크하거나 내장하는 경우 전체 앱은 GPL이어야 함)에 따라 사용되지만 앱에 링크하지 않고 CLI 도구로 사용하거나 공유 라이브러리를 통해 사용하므로 이 경우 일반적으로 상업적 사용에 문제가 없습니다 (Poppler의 데비안 패키징 가이드라인 참조).
라이선스를 100% 안전하게 보호하려면 pdftoppm다음을 통해 호출할 수 있습니다 subprocess( pdf2image필수 없음):
import os
import tempfile
import subprocess
from pathlib import Path
def convert_pdf_to_images ( pdf_path, dpi= 300 ):
with tempfile.TemporaryDirectory() as tmpdir:
output_prefix = os.path.join(tmpdir, "page" )
# Poppler의 pdftoppm 호출
subprocess.run([
"pdftoppm" ,
"-png" ,
"-r" , str (dpi),
pdf_path,
output_prefix
], check= True )
# 생성된 모든 PNG 이미지를 바이트 배열로 수집
png_files = sorted (Path(tmpdir).glob( "*.png" ))
images_bytes = [Path(p).read_bytes() for p in png_files]
return images_bytes
에필로그 2: 페이지 단위로 처리하는 것이 더 스마트한 이유
모든 페이지를 한 번에 처리하는 경우:
- 특히 출력을 위해 모델의 컨텍스트 창을 오버로드합니다 .
- 출력이 차단되거나 완전히 실패할 위험이 있습니다 .
- 컴퓨팅을 낭비하다 보면 한 번의 실수로 모든 것이 망가집니다.
대신, 규칙적으로 메모하는 사람처럼 한 번에 한 페이지씩 처리하세요.
페이지별 추출을 위한 개선된 코드는 다음과 같습니다.
import fitz # PDF용 PyMuPDF
import ollama
import io
from PIL import Image
def convert_pdf_to_images ( pdf_path ):
"""페이지당 하나의 PNG 바이트 스트림을 생성합니다. 거대한 목록도 없고 메모리 폭탄도 없습니다."""
doc = fitz. open (pdf_path)
for page_num in range ( len (doc)):
pix = doc[page_num].get_pixmap()
img = Image.frombytes( "RGB" , [pix.width, pix.height], pix.samples)
buf = io.BytesIO()
img.save(buf, format = "PNG" )
yield page_num + 1 , buf.getvalue()
def query_llm_with_image ( image_bytes, model= "gemma3:12b" , prompt= None ):
if prompt is None :
prompt = (
"이 이미지에서 읽을 수 있는 모든 텍스트를 추출하여 구조화된 마크다운으로 포맷합니다."
)
response = ollama.chat(
model=model,
messages=[{
"role" : "user" ,
"content" : prompt,
"images" : [image_bytes]
}]
)
return response[ "message" ][ "content" ]
def extract_pdf_to_markdown ( pdf_path, output_file= "output.md" , prompt= None ):
with open (output_file, "w" , encoding= "utf-8" ) as md_file:
for page_number, image_bytes in convert_pdf_to_images(pdf_path):
print ( f"페이지 {page_number} 처리 중 ..." )
try :
markdown = query_llm_with_image(image_bytes, prompt=prompt)
md_file.write( f"\n\n## 페이지 {page_number} \n\n" )
md_file.write(markdown)
except Exception as e:
print ( f"페이지 {page_number} 처리 중 오류 :{e} " )
md_file.write( f"\n\n## 페이지{page_number} (오류)\n\n" )
md_file.write( f"_이 페이지에서 콘텐츠를 추출하는 중 오류가 발생했습니다._" )
print ( f"\n 완료! 마크다운이 {output_file} 에 저장되었습니다. " )
# 사용법
if __name__ == '__main__' :
pdf_path = "mypdf.pdf"
out_path = "output.md"
prompt = "이 이미지에서 읽을 수 있는 모든 텍스트와 텍스트 청크를 추출합니다." + \
" 구조화된 마크다운으로 서식을 지정합니다." + \
" 항상 전체 이미지를 보고 모든 텍스트를 검색해 보세요!"
extract_pdf_to_markdown(pdf_path, output_file=out_path, prompt=prompt)
gemma3:12b 또는 gemma3:4b를 사용하는 이유는 무엇입니까?
이러한 모델은 완벽하기 때문이 아니라 단지 예시로 선택되었습니다 . 그 이유는 다음과 같습니다.
- GPU를 손상시키지 않고 대부분의 최신 노트북이나 PC에 장착 가능합니다.
- 그들은 Ollama를 통해 로컬로 이미지 입력을 지원합니다.
- 그것은 실험을 위한 좋은 시작점입니다
'AI > Tool, 모델 소개' 카테고리의 다른 글
MCP vs. A2A: AI 개발의 두 신성, 미래를 이끌 주자는 누구인가? (2) | 2025.05.29 |
---|---|
혁신적인 AI 모델, SignGemma 공개! (1) | 2025.05.28 |
"카카오, 자체 개발 ‘Kanana’ 언어모델 4종 오픈소스 공개" (2) | 2025.05.23 |
코딩의 판도를 바꿀 새로운 강자 등장! Claude 4 시리즈 (2) | 2025.05.23 |
Flowith.io: 이제 코딩 없이도 AI 워크플로우 생성(추천코드 배포) (4) | 2025.05.23 |