- Published on
LangChain+OpenAI로 멀티모달 PDF RAG 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서류 업무에서 가장 자주 마주치는 데이터는 PDF입니다. 문제는 PDF가 단순 텍스트 덩어리가 아니라는 점입니다. 본문 텍스트, 표, 캡처 이미지, 스캔 문서(이미지 기반), 각주와 레이아웃까지 뒤섞여 있고, 이 구조가 검색·요약·질문응답 정확도를 크게 흔듭니다.
이 글에서는 LangChain과 OpenAI를 이용해 멀티모달 PDF RAG(Retrieval-Augmented Generation)를 구현하는 방법을 정리합니다. 목표는 다음과 같습니다.
- 텍스트 기반 PDF는 물론, 이미지/스캔 기반 페이지도 처리
- 표/그림 같은 비정형 요소도 “검색 가능한 지식”으로 변환
- 답변에는 출처(페이지/좌표/이미지)까지 남겨 감사 가능하게 구성
- 비용과 지연시간을 통제할 수 있는 인덱싱 전략 적용
아래 구현은 Python을 기준으로 설명합니다.
멀티모달 PDF RAG 아키텍처 개요
멀티모달 PDF RAG는 보통 2단 인덱싱(또는 2트랙 인덱싱)으로 설계합니다.
추출(Extraction)
- 텍스트 레이어가 있는 PDF: 텍스트/레이아웃/표를 직접 추출
- 스캔 PDF: 페이지 이미지를 만들고 OCR 수행
- 그림/차트: 이미지 캡션 생성(비전 모델) 또는 OCR로 텍스트화
정규화(Normalization)
- “청크” 단위로 쪼개고, 메타데이터(파일명, 페이지, 섹션, bbox 등)를 부여
- 표는 CSV/Markdown 등 일관된 텍스트 표현으로 변환
색인(Indexing)
- 텍스트 청크는 임베딩하여 벡터DB 저장
- 이미지(페이지/그림)는
- (A) 이미지 자체를 멀티모달 임베딩에 넣거나
- (B) 이미지에서 생성한 캡션/요약 텍스트를 임베딩에 넣는 방식 중 선택
검색(Retrieval)
- 질의에 대해 텍스트 벡터 검색
- 필요하면 이미지 캡션 인덱스도 함께 검색
- 상위 결과를 재랭킹(선택)하여 컨텍스트 품질 향상
생성(Generation)
- 검색된 청크를 근거로 답변 생성
- 출처를 페이지 단위로 묶어 함께 반환
핵심 설계: “이미지를 어떻게 검색 가능하게 만들 것인가”
멀티모달 PDF에서 가장 큰 난제는 이미지/표/차트입니다. 실무에서는 다음 3가지 전략이 자주 쓰입니다.
1) 이미지 캡션 텍스트화 후 텍스트 임베딩(가장 단순)
- 장점: 구현이 쉽고, 기존 텍스트 RAG 파이프라인을 그대로 활용
- 단점: 캡션 품질이 낮으면 검색 품질이 바로 떨어짐
2) OCR + 구조화(표/스크린샷에 강함)
- 장점: 표/스크린샷/스캔 문서에서 강력
- 단점: OCR 오류와 레이아웃 복원이 관건
3) 멀티모달 임베딩으로 이미지 자체를 벡터화(가장 이상적)
- 장점: “그림 자체”를 질의와 매칭할 수 있음
- 단점: 운영 난이도/비용 상승, 모델/SDK 제약
이 글의 구현 예시는 1) + 2) 조합으로 설명합니다. 즉,
- 페이지 이미지를 만들고 OCR로 텍스트를 얻고
- 이미지/차트는 캡션을 생성해 “텍스트 청크”로 저장해 검색 가능하게 만들고
- 최종 답변에는 해당 페이지 이미지/좌표 메타데이터를 남깁니다.
구현 준비: 패키지와 환경변수
아래 예시는 LangChain과 OpenAI SDK를 사용합니다. 벡터DB는 로컬 개발이 쉬운 FAISS를 예로 들지만, 운영에서는 Milvus, pgvector, Pinecone 등을 쓰는 편이 일반적입니다.
pip install -U langchain langchain-community langchain-openai openai pypdf pymupdf pillow pytesseract faiss-cpu
OpenAI 키 설정:
export OPENAI_API_KEY="your-key"
1단계: PDF에서 페이지 이미지 생성 + 텍스트 추출
멀티모달 PDF에서 안전한 방식은 “페이지 단위”로 처리하는 것입니다. 페이지 단위로 텍스트/이미지/메타데이터를 결합하면 출처 추적이 쉬워집니다.
아래는 PyMuPDF로 페이지 이미지를 만들고, PyPDF로 텍스트 레이어를 추출하는 예시입니다.
from pypdf import PdfReader
import fitz # pymupdf
from PIL import Image
import io
def extract_pdf_pages(pdf_path: str):
# 텍스트 레이어
reader = PdfReader(pdf_path)
# 페이지 렌더링(이미지)
doc = fitz.open(pdf_path)
pages = []
for i in range(len(doc)):
page = doc.load_page(i)
# 이미지 렌더링 해상도: 2.0~3.0 배율을 많이 사용
mat = fitz.Matrix(2.0, 2.0)
pix = page.get_pixmap(matrix=mat)
img_bytes = pix.tobytes("png")
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
text = ""
try:
text = reader.pages[i].extract_text() or ""
except Exception:
text = ""
pages.append({
"page_number": i + 1,
"page_text": text,
"page_image": pil_img,
})
doc.close()
return pages
여기서 page_text가 비어 있거나 품질이 낮으면 해당 페이지는 스캔 기반일 확률이 높습니다. 이런 경우 OCR을 수행해 보강합니다.
2단계: OCR로 스캔 페이지 텍스트 보강
Tesseract 기반 OCR은 빠르게 붙일 수 있지만, 한글/표/회전/노이즈에서 품질이 들쑥날쑥합니다. 그래도 “검색용 텍스트”를 만드는 목적이라면 충분히 효과가 있습니다.
import pytesseract
def ocr_page_image(pil_img, lang: str = "kor+eng") -> str:
# 필요하면 전처리(그레이스케일, 이진화, 샤프닝 등)를 추가
text = pytesseract.image_to_string(pil_img, lang=lang)
return text.strip()
운영 팁:
- OCR 텍스트는 그대로 쓰기보다, 후처리로 공백/줄바꿈/하이픈 분리 등을 정리하면 검색 품질이 올라갑니다.
- 페이지 텍스트 레이어가 이미 충분히 좋다면 OCR은 생략해 비용과 시간을 줄입니다.
3단계: 이미지/차트 캡션 생성(선택)
PDF에는 “텍스트로 표현되지 않는 정보”가 많습니다. 예를 들어 차트의 추세, 다이어그램의 관계, 스크린샷의 UI 상태 등입니다. 이를 검색 가능하게 만들려면 이미지에서 캡션을 생성해 텍스트로 저장하는 전략이 유효합니다.
아래 코드는 OpenAI 비전 모델을 통해 페이지 이미지에 대한 캡션을 생성하는 예시입니다. 주의할 점은, 모델/SDK는 시점에 따라 바뀔 수 있으므로 현재 사용 중인 OpenAI 공식 문서의 이미지 입력 포맷에 맞추어 조정해야 합니다.
import base64
import io
from openai import OpenAI
client = OpenAI()
def image_to_base64_png(pil_img) -> str:
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
def caption_page_image(pil_img) -> str:
b64 = image_to_base64_png(pil_img)
# 모델명은 환경에 맞게 교체
resp = client.responses.create(
model="gpt-4.1-mini",
input=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "이 이미지를 문서 검색용으로 자세히 캡션해줘. 표/차트가 있으면 핵심 수치와 축 의미를 요약해줘."},
{"type": "input_image", "image_url": f"data:image/png;base64,{b64}"},
],
}
],
)
return resp.output_text.strip()
실무에서는 페이지 전체를 캡션하기보다,
- 페이지 내 이미지 영역만 잘라 캡션하거나
- “차트/표가 있는 페이지만” 캡션하는 식으로 비용을 통제합니다.
4단계: LangChain 문서(Document)로 표준화
이제 페이지별로 다음을 합칩니다.
- 텍스트 레이어
- OCR 보강 텍스트
- 이미지 캡션(선택)
그리고 LangChain의 Document로 만들고 메타데이터에 페이지 번호, 파일명 등을 넣습니다.
from langchain_core.documents import Document
def build_documents(pdf_path: str, do_ocr: bool = True, do_caption: bool = False):
pages = extract_pdf_pages(pdf_path)
docs = []
for p in pages:
page_text = (p["page_text"] or "").strip()
ocr_text = ""
if do_ocr and len(page_text) < 50:
ocr_text = ocr_page_image(p["page_image"]) # 스캔 페이지 보강
caption = ""
if do_caption:
caption = caption_page_image(p["page_image"]) # 비용 주의
merged = "\n\n".join([
"[PDF_TEXT]" + "\n" + page_text,
"[OCR_TEXT]" + "\n" + ocr_text,
"[IMAGE_CAPTION]" + "\n" + caption,
]).strip()
docs.append(
Document(
page_content=merged,
metadata={
"source": pdf_path,
"page": p["page_number"],
},
)
)
return docs
여기서 [PDF_TEXT], [OCR_TEXT] 같은 섹션 태그를 넣는 이유는, LLM이 컨텍스트를 읽을 때 “이 텍스트가 어디서 왔는지”를 구분하게 해 환각을 줄이는 데 도움이 되기 때문입니다.
5단계: 청킹(Chunking)과 임베딩, 벡터 인덱스 생성
페이지 단위 문서를 그대로 벡터화하면 컨텍스트가 너무 커져 검색 정밀도가 떨어질 수 있습니다. 일반적으로는 페이지를 청크로 쪼개고, 각 청크가 원본 페이지 메타데이터를 유지하도록 합니다.
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
def index_documents(docs):
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
)
chunks = splitter.split_documents(docs)
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
vs = FAISS.from_documents(chunks, embeddings)
return vs
운영 팁:
- 표/리스트가 많은 문서는
chunk_size를 줄이고overlap을 늘리면 문맥 단절이 줄어듭니다. - OCR 텍스트는 노이즈가 많아 청크 품질이 떨어질 수 있으므로, OCR 텍스트만 별도로 정제하거나 가중치를 낮추는 전략도 고려합니다.
6단계: Retrieval + 답변 생성 체인 구성
이제 질의가 들어오면 벡터 검색으로 관련 청크를 가져오고, LLM이 근거 기반으로 답변하도록 프롬프트를 구성합니다.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
def answer_question(vs, question: str):
retriever = vs.as_retriever(search_kwargs={"k": 6})
docs = retriever.invoke(question)
context = "\n\n".join(
[f"[p.{d.metadata.get('page')}]\n{d.page_content}" for d in docs]
)
prompt = ChatPromptTemplate.from_messages([
("system", "너는 문서 기반 QA 어시스턴트다. 제공된 컨텍스트에 근거해서만 답하고, 근거가 없으면 모른다고 말해라."),
("user", "질문: {question}\n\n컨텍스트:\n{context}\n\n요구사항: 답변 끝에 근거 페이지 번호를 나열해라."),
])
llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
msg = prompt.format_messages(question=question, context=context)
resp = llm.invoke(msg)
return {
"answer": resp.content,
"sources": [d.metadata for d in docs],
}
이 구성의 장점은 단순함입니다. 하지만 멀티모달 PDF는 노이즈가 많기 때문에, 실무에서는 보통 다음을 추가합니다.
- 재랭킹: 상위
k를 더 크게 가져온 뒤 Cross-Encoder 또는 LLM으로 재정렬 - 질의 확장: 약어/제품명/표현 변형이 많은 도메인은 쿼리 리라이트가 효과적
- 출처 포맷 표준화:
source,page, 가능하면bbox까지 저장
품질을 올리는 실전 팁 7가지
1) “페이지”와 “청크” 메타데이터를 분리해 저장
청크는 검색 단위이고, 페이지는 출처 단위입니다. 청크 메타데이터에 반드시 page를 넣어야 답변에서 출처를 정확히 붙일 수 있습니다.
2) OCR 텍스트는 그대로 섞지 말고 정제 파이프라인을 둔다
OCR 결과는 줄바꿈/공백/특수문자 노이즈가 많습니다. 간단한 정규화만 해도 검색 품질이 체감됩니다.
3) 표는 “Markdown 표” 또는 “CSV”로 통일
표를 텍스트로 풀 때 포맷이 일관되지 않으면 임베딩이 흔들립니다. 가능하면 표 전용 파서를 쓰거나, 최소한 행/열 구분이 유지되는 형태로 변환하세요.
4) 이미지 캡션은 전부 하지 말고 “유의미한 페이지만”
캡션 생성은 비용이 큽니다. 예를 들어 다음 조건일 때만 캡션을 만들 수 있습니다.
page_text길이가 매우 짧음(스캔)- 특정 키워드(예: “Figure”, “표”, “Chart”)가 존재
- 페이지에 이미지 객체 수가 많음
5) 벡터DB 운영에서는 TTL/정리 전략을 같이 설계
문서가 자주 업데이트되면 “오래된 임베딩”이 남아 검색을 오염시킵니다. 인덱스 수명주기를 설계할 때 TTL이나 버전 키를 두는 것이 안전합니다. 이 관점은 AutoGPT 메모리 누수? 벡터DB TTL로 해결하기에서 다룬 내용과도 연결됩니다.
6) 대용량 PDF 업로드는 인프라 레이어에서 먼저 터진다
멀티모달은 페이지 이미지 생성/OCR로 페이로드가 커집니다. gRPC나 프록시를 쓰는 경우 메시지 제한에 걸려 502가 나기도 합니다. 쿠버네티스/EKS 환경이라면 EKS에서 413 없이 502? gRPC 최대 메시지 해결 같은 이슈를 미리 점검하세요.
7) PDF 원본 저장소(S3) 권한/암호화 이슈를 먼저 해결
인덱싱 파이프라인이 S3에서 PDF를 읽어오면, KMS/버킷 정책/VPC 엔드포인트 설정 때문에 403이 자주 발생합니다. 운영에서 가장 흔한 장애 포인트라서 AWS S3 AccessDenied 403 - 정책·KMS·VPCE 점검을 체크리스트로 두면 좋습니다.
확장: “진짜 멀티모달”로 가려면
앞서 설명한 방식은 멀티모달 입력을 텍스트로 변환해 검색하는 접근입니다. 더 높은 수준의 멀티모달 RAG를 원한다면 다음을 고려합니다.
- 이미지 자체를 임베딩하는 멀티모달 임베딩 모델 적용
- 페이지 내 객체 탐지(그림/표 영역)를 하고, 영역별로 캡션/요약 생성
- 답변 시 해당 영역을 하이라이트한 썸네일을 함께 제공
- 재랭킹 단계에 비전 정보를 포함(예: 질의가 “그래프 추세”일 때 이미지 캡션을 우선)
다만 이 단계는 비용과 복잡도가 빠르게 증가합니다. 먼저 “텍스트+OCR+선택적 캡션” 조합으로 MVP를 만들고, 실패 케이스(표 인식, 차트 질의, 스캔 품질)를 수집해 점진적으로 고도화하는 전략이 가장 안전합니다.
마무리
LangChain+OpenAI로 멀티모달 PDF RAG를 구현할 때 핵심은 “PDF를 페이지 단위로 정규화하고, 텍스트로 환원 가능한 신호(OCR/캡션/표 변환)를 최대한 확보한 뒤, 청킹과 메타데이터로 출처 추적성을 유지하는 것”입니다.
이 구조를 갖추면, 단순 QnA를 넘어 다음까지 확장할 수 있습니다.
- 근거 페이지 자동 첨부 보고서 생성
- 사내 규정/매뉴얼 기반의 감사 가능한 답변 시스템
- 변경된 PDF만 재인덱싱하는 증분 파이프라인
원한다면 다음 단계로, Milvus나 pgvector로의 운영 전환(스키마/필터링/하이브리드 검색), 재랭킹 추가, 표 전용 파서 도입까지 이어서 설계해볼 수 있습니다.