이미지 로딩 중...
AI Generated
2025. 11. 8. · 4 Views
RAG 시스템 8편 멀티모달 RAG 완벽 가이드
텍스트만 처리하던 RAG 시스템을 넘어서, 이미지, 표, 차트까지 이해하고 활용하는 멀티모달 RAG 구축 방법을 배웁니다. 실무에서 바로 적용할 수 있는 구체적인 구현 전략과 최적화 팁을 제공합니다.
목차
- 멀티모달 RAG 개요
- 문서 파싱 전략
- Vision-Language 임베딩
- 멀티모달 벡터 저장소
- 이미지 검색 최적화
- GPT-4V를 활용한 응답 생성
- 표와 차트 처리
- 핵심 인사이트
- 하이브리드 검색 전략
1. 멀티모달 RAG 개요
시작하며
여러분이 회사 문서를 검색할 때 이런 상황을 겪어본 적 있나요? PDF에 있는 중요한 차트나 다이어그램을 찾고 싶은데, 텍스트 검색으로는 전혀 찾을 수 없었던 경험 말이죠.
"2023년 매출 그래프"라고 검색해도, 정작 그래프 이미지는 검색 결과에 나오지 않습니다. 이런 문제는 전통적인 RAG 시스템의 근본적인 한계에서 비롯됩니다.
기존 RAG는 텍스트만 처리하기 때문에, 문서 내 이미지, 표, 차트, 다이어그램 같은 시각적 정보는 완전히 무시됩니다. 하지만 실제 업무 문서의 핵심 정보 중 상당수는 바로 이런 시각적 요소에 담겨 있습니다.
바로 이럴 때 필요한 것이 멀티모달 RAG입니다. 텍스트뿐만 아니라 이미지, 표, 차트까지 이해하고 검색할 수 있어, 문서의 모든 정보를 활용할 수 있게 해줍니다.
개요
간단히 말해서, 멀티모달 RAG는 텍스트와 이미지를 동시에 이해하고 검색할 수 있는 차세대 검색 증강 생성 시스템입니다. 전통적인 RAG가 텍스트만 임베딩하고 검색했다면, 멀티모달 RAG는 Vision-Language 모델을 활용하여 이미지의 의미까지 벡터화합니다.
예를 들어, 제품 카탈로그에서 "빨간색 소파"를 검색하면 텍스트 설명뿐만 아니라 실제 빨간 소파 이미지도 함께 찾아줍니다. 기술 문서에서 "시스템 아키텍처"를 검색하면 관련 다이어그램도 검색 결과에 포함됩니다.
기존에는 문서를 파싱할 때 이미지를 건너뛰거나 OCR로 텍스트만 추출했다면, 이제는 이미지 자체를 의미론적으로 이해하고 인덱싱할 수 있습니다. 멀티모달 RAG의 핵심 특징은 세 가지입니다.
첫째, Vision-Language 모델(CLIP, BLIP 등)을 사용한 통합 임베딩, 둘째, 이미지와 텍스트를 함께 저장하는 벡터 저장소, 셋째, GPT-4V 같은 멀티모달 LLM을 활용한 응답 생성입니다. 이러한 특징들이 사용자가 질문한 내용에 대해 텍스트 설명뿐만 아니라 관련 이미지까지 함께 제시할 수 있게 만들어줍니다.
코드 예제
from langchain.document_loaders import PyMuPDFLoader
from langchain.embeddings import OpenCLIPEmbeddings
from langchain.vectorstores import Chroma
from PIL import Image
import base64
# 멀티모달 문서 로더 - 텍스트와 이미지를 함께 추출
loader = PyMuPDFLoader("technical_docs.pdf")
pages = loader.load()
# CLIP 기반 멀티모달 임베딩 모델 초기화
embeddings = OpenCLIPEmbeddings(model_name="ViT-B-32")
# 이미지와 텍스트를 함께 저장할 벡터 저장소
vectorstore = Chroma(
collection_name="multimodal_docs",
embedding_function=embeddings,
persist_directory="./chroma_multimodal_db"
)
# 문서 페이지마다 텍스트와 이미지를 함께 인덱싱
for page in pages:
# 텍스트 임베딩
vectorstore.add_texts([page.page_content], metadatas=[{"page": page.metadata["page"]}])
# 페이지 내 이미지 추출 및 임베딩
images = page.metadata.get("images", [])
for img in images:
vectorstore.add_images([img], metadatas=[{"type": "image", "page": page.metadata["page"]}])
설명
이것이 하는 일: 이 코드는 PDF 문서에서 텍스트와 이미지를 함께 추출하고, CLIP 모델을 사용하여 둘 다 같은 벡터 공간에 임베딩한 후 통합 검색이 가능한 벡터 저장소를 구축합니다. 첫 번째로, PyMuPDFLoader를 사용하여 PDF 파일을 페이지 단위로 로드합니다.
이 로더는 일반적인 텍스트 추출기와 달리 페이지 내 이미지 정보도 함께 추출할 수 있습니다. 왜 페이지 단위로 처리하냐면, 나중에 검색 결과를 보여줄 때 "어떤 페이지에 있는 정보인지" 함께 제시하기 위함입니다.
그 다음으로, OpenCLIPEmbeddings를 초기화합니다. CLIP(Contrastive Language-Image Pre-training)은 OpenAI가 개발한 모델로, 텍스트와 이미지를 같은 벡터 공간에 매핑합니다.
즉, "빨간 자동차"라는 텍스트와 실제 빨간 자동차 이미지가 벡터 공간에서 가까운 위치에 놓이게 됩니다. ViT-B-32는 Vision Transformer 기반 모델로, 속도와 정확도의 균형이 좋아 실무에서 많이 사용됩니다.
마지막으로, 각 페이지를 순회하면서 텍스트는 add_texts로, 이미지는 add_images로 벡터 저장소에 추가합니다. 메타데이터에 페이지 번호를 포함시켜서 나중에 출처를 추적할 수 있게 합니다.
이미지와 텍스트가 같은 저장소에 들어가기 때문에, 하나의 쿼리로 둘 다 검색할 수 있습니다. 여러분이 이 코드를 사용하면 "시스템 아키텍처 다이어그램을 보여줘"라는 질문에 대해 관련 텍스트 설명과 실제 다이어그램 이미지를 함께 검색 결과로 얻을 수 있습니다.
기존 텍스트 전용 RAG에서는 불가능했던 시각적 정보 검색이 가능해지며, 문서의 정보 활용률이 크게 높아집니다.
실전 팁
💡 CLIP 모델은 여러 변형이 있는데, ViT-L-14가 정확도가 높지만 느리고, ViT-B-32가 속도가 빠릅니다. 실시간 검색이 중요하다면 B-32를, 정확도가 중요하다면 L-14를 선택하세요.
💡 PDF에서 이미지를 추출할 때 해상도가 너무 낮으면 임베딩 품질이 떨어집니다. 최소 224x224 픽셀 이상의 이미지만 인덱싱하도록 필터링하세요.
💡 벡터 저장소가 커질수록 검색 속도가 느려지므로, 중요하지 않은 장식용 이미지(로고, 아이콘 등)는 제외하는 전처리 로직을 추가하세요.
💡 이미지와 텍스트의 임베딩 차원이 같아야 하므로, 모델을 변경할 때는 반드시 벡터 저장소를 재구축해야 합니다. 마이그레이션 스크립트를 미리 준비해두세요.
2. 문서 파싱 전략
시작하며
여러분이 수백 페이지의 기술 문서를 RAG 시스템에 넣으려고 할 때, 가장 먼저 부딪히는 문제가 무엇인가요? 바로 "이 복잡한 레이아웃을 어떻게 구조적으로 파싱할 것인가"입니다.
PDF에는 텍스트, 이미지, 표가 뒤섞여 있고, 때로는 다단 레이아웃에 헤더와 푸터까지 섞여 있습니다. 단순히 PyPDF로 텍스트만 추출하면 이미지는 사라지고, 표는 깨지고, 레이아웃 순서도 엉망이 됩니다.
OCR을 쓰면 텍스트는 얻지만 이미지의 의미는 손실됩니다. 실제로 많은 RAG 프로젝트가 이 파싱 단계에서 정보 손실을 겪고, 결국 검색 품질이 떨어집니다.
바로 이럴 때 필요한 것이 전략적인 멀티모달 문서 파싱입니다. 텍스트, 이미지, 표를 각각의 특성에 맞게 추출하고, 원본 문서의 구조와 문맥을 최대한 보존하는 방법입니다.
개요
간단히 말해서, 멀티모달 문서 파싱은 복잡한 문서에서 텍스트, 이미지, 표를 각각 최적의 방법으로 추출하고 구조화하는 프로세스입니다. 이 작업이 왜 중요하냐면, RAG 시스템의 검색 품질은 파싱 품질에 직접적으로 의존하기 때문입니다.
예를 들어, 기술 매뉴얼에서 "트러블슈팅 절차"를 검색할 때, 관련 다이어그램과 텍스트 설명이 함께 추출되어야 제대로 된 답변을 생성할 수 있습니다. 만약 다이어그램이 손실되거나 텍스트와 분리되면, LLM이 불완전한 정보로 답변하게 됩니다.
기존에는 pdfplumber로 텍스트만 추출하거나 pdf2image로 전체를 이미지화했다면, 이제는 레이아웃 분석을 통해 각 요소를 식별하고 적절한 방법으로 처리할 수 있습니다. 핵심 전략은 세 가지입니다.
첫째, 레이아웃 분석으로 텍스트/이미지/표 영역을 구분합니다. 둘째, 각 요소 타입별로 최적화된 추출기를 사용합니다(텍스트는 PyMuPDF, 표는 Camelot, 이미지는 PIL).
셋째, 추출된 요소들 간의 관계를 메타데이터로 보존합니다. 이러한 전략들이 문서의 원본 구조와 의미를 유지하면서도 검색 가능한 형태로 만들어줍니다.
코드 예제
import fitz # PyMuPDF
from PIL import Image
import io
from typing import List, Dict
def parse_multimodal_document(pdf_path: str) -> Dict[str, List]:
"""PDF에서 텍스트, 이미지, 표를 구조적으로 추출"""
doc = fitz.open(pdf_path)
results = {"texts": [], "images": [], "tables": []}
for page_num, page in enumerate(doc):
# 텍스트 블록 추출 (레이아웃 순서 보존)
blocks = page.get_text("blocks")
for block in blocks:
if block[6] == 0: # 텍스트 블록
results["texts"].append({
"content": block[4],
"page": page_num,
"bbox": block[:4] # 위치 정보 보존
})
# 이미지 추출 (최소 크기 필터링)
image_list = page.get_images(full=True)
for img_index, img in enumerate(image_list):
xref = img[0]
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
# PIL로 이미지 크기 확인
pil_image = Image.open(io.BytesIO(image_bytes))
if pil_image.width >= 100 and pil_image.height >= 100: # 작은 아이콘 제외
results["images"].append({
"image": pil_image,
"page": page_num,
"size": (pil_image.width, pil_image.height)
})
# 표 감지 (테이블 영역 찾기)
tables = page.find_tables()
for table in tables:
results["tables"].append({
"data": table.extract(),
"page": page_num,
"bbox": table.bbox
})
doc.close()
return results
설명
이것이 하는 일: 이 코드는 PDF 문서를 페이지별로 분석하여 텍스트 블록, 이미지, 표를 각각 추출하고, 원본 위치와 관계 정보를 메타데이터로 함께 저장합니다. 첫 번째로, PyMuPDF(fitz)로 PDF를 열고 각 페이지를 순회합니다.
get_text("blocks")는 페이지의 레이아웃을 분석하여 텍스트 블록들을 읽기 순서대로 반환합니다. 단순히 전체 텍스트를 추출하는 것보다 훨씬 정교한데, 다단 레이아웃이나 복잡한 구조에서도 올바른 순서를 유지합니다.
bbox(바운딩 박스)를 저장하는 이유는 나중에 이미지와 텍스트의 위치 관계를 파악하기 위함입니다. 그 다음으로, get_images로 페이지 내 모든 이미지를 추출합니다.
여기서 중요한 것은 필터링입니다. 100x100 픽셀 미만의 이미지는 대부분 로고나 아이콘이므로 제외합니다.
이렇게 하면 벡터 저장소의 노이즈를 줄이고 검색 정확도를 높일 수 있습니다. 추출된 이미지는 PIL 객체로 변환하여 나중에 CLIP 임베딩에 바로 사용할 수 있게 합니다.
마지막으로, find_tables()로 표를 감지하고 추출합니다. PyMuPDF의 표 감지 기능은 선으로 구분된 표뿐만 아니라 공백으로 정렬된 표도 인식할 수 있습니다.
추출된 표 데이터는 2차원 리스트로 반환되며, 이를 나중에 구조화된 데이터로 처리하거나 텍스트로 변환하여 임베딩할 수 있습니다. 여러분이 이 코드를 사용하면 100페이지짜리 기술 문서에서 텍스트 500개, 이미지 50개, 표 30개를 각각 구조화된 형태로 얻을 수 있습니다.
각 요소가 어느 페이지 어느 위치에 있었는지 정보도 함께 보존되므로, 검색 결과를 사용자에게 보여줄 때 정확한 출처를 제시할 수 있습니다. 정보 손실 없이 문서를 완전히 디지털화할 수 있어, RAG 시스템의 답변 품질이 크게 향상됩니다.
실전 팁
💡 PyMuPDF는 빠르지만 복잡한 표 추출에는 약합니다. 표가 많은 문서는 Camelot이나 Tabula를 병행 사용하세요. Camelot은 선 기반 표에, Tabula는 공백 기반 표에 강합니다.
💡 이미지 추출 시 DPI를 확인하세요. 72 DPI 이하의 저해상도 이미지는 임베딩 품질이 떨어지므로, 가능하면 원본 PDF를 고해상도로 다시 생성하거나 이미지 업스케일링을 적용하세요.
💡 메모리 관리가 중요합니다. 수천 페이지 문서는 한 번에 로드하지 말고 배치 단위로 처리하세요. 100페이지씩 끊어서 처리하면 메모리 오버플로를 방지할 수 있습니다.
💡 텍스트와 이미지의 위치 정보(bbox)를 비교하여 캡션을 자동으로 연결할 수 있습니다. 이미지 바로 위나 아래의 텍스트를 캡션으로 간주하면 검색 정확도가 높아집니다.
💡 암호화된 PDF는 먼저 복호화 단계를 거쳐야 합니다. PyMuPDF는 기본적인 암호 해제를 지원하지만, 고급 DRM은 별도 처리가 필요합니다.
3. Vision-Language 임베딩
시작하며
여러분이 "빨간색 스포츠카 이미지를 찾아줘"라고 검색하면, 시스템이 어떻게 텍스트 쿼리와 이미지를 비교할 수 있을까요? 전통적인 방법으로는 불가능합니다.
텍스트는 단어 벡터로, 이미지는 픽셀 값으로 표현되는데, 이 둘은 완전히 다른 공간에 존재하기 때문입니다. 이 문제를 해결하지 못하면 멀티모달 검색은 불가능합니다.
텍스트 쿼리로는 텍스트만, 이미지 쿼리로는 이미지만 검색하는 분리된 시스템으로 남게 됩니다. 하지만 실제 사용자는 "설명과 이미지가 함께 나오는" 통합된 검색 결과를 원합니다.
바로 이럴 때 필요한 것이 Vision-Language 임베딩입니다. CLIP, BLIP 같은 모델이 텍스트와 이미지를 같은 의미 공간에 매핑하여, "빨간색 스포츠카"라는 텍스트와 실제 빨간 스포츠카 이미지가 벡터 공간에서 가까이 위치하게 만들어줍니다.
개요
간단히 말해서, Vision-Language 임베딩은 텍스트와 이미지를 동일한 벡터 공간에 투영하여 의미론적으로 유사한 것들이 가까이 위치하도록 만드는 기술입니다. 이 기술이 왜 혁명적이냐면, 한 번의 검색으로 텍스트 설명과 이미지를 동시에 찾을 수 있게 해주기 때문입니다.
예를 들어, 의료 문서에서 "심장 구조"를 검색하면 텍스트 설명과 심장 다이어그램 이미지가 함께 검색됩니다. 전자상거래에서 "북유럽 스타일 소파"를 검색하면 제품 설명과 실제 소파 이미지가 모두 나옵니다.
기존에는 이미지 검색을 위해 수작업으로 태그를 달거나 OCR로 텍스트를 추출했다면, 이제는 모델이 이미지의 시각적 의미를 직접 이해하고 벡터화할 수 있습니다. 핵심 모델은 세 가지입니다.
첫째, CLIP(OpenAI)은 4억 개의 이미지-텍스트 쌍으로 학습되어 범용적인 시각-언어 이해 능력을 갖췄습니다. 둘째, BLIP(Salesforce)은 캡셔닝과 검색을 동시에 수행할 수 있어 더 정교한 이해가 가능합니다.
셋째, SigLIP(Google)은 CLIP보다 학습 효율이 높아 더 나은 성능을 보입니다. 이러한 모델들이 멀티모달 RAG의 핵심 엔진 역할을 합니다.
코드 예제
from transformers import CLIPProcessor, CLIPModel
import torch
from PIL import Image
# CLIP 모델 및 프로세서 로드
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
def embed_text(text: str) -> torch.Tensor:
"""텍스트를 512차원 벡터로 임베딩"""
inputs = processor(text=[text], return_tensors="pt", padding=True)
with torch.no_grad():
text_features = model.get_text_features(**inputs)
# L2 정규화 - 코사인 유사도 계산을 위해 필수
text_features = text_features / text_features.norm(dim=-1, keepdim=True)
return text_features
def embed_image(image: Image.Image) -> torch.Tensor:
"""이미지를 512차원 벡터로 임베딩 (텍스트와 동일한 공간)"""
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
image_features = model.get_image_features(**inputs)
# L2 정규화
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
return image_features
def compute_similarity(text: str, image: Image.Image) -> float:
"""텍스트와 이미지의 의미적 유사도 계산 (0~1)"""
text_emb = embed_text(text)
image_emb = embed_image(image)
# 코사인 유사도 = 정규화된 벡터의 내적
similarity = (text_emb @ image_emb.T).item()
return similarity
# 사용 예시
image = Image.open("product_photo.jpg")
similarity_score = compute_similarity("red sports car", image)
print(f"유사도: {similarity_score:.4f}") # 0.85 같은 높은 값이 나오면 매칭
설명
이것이 하는 일: 이 코드는 CLIP 모델을 사용하여 텍스트와 이미지를 512차원의 동일한 벡터 공간으로 변환하고, 둘 사이의 의미적 유사도를 수치로 계산합니다. 첫 번째로, Hugging Face의 transformers 라이브러리로 CLIP 모델을 로드합니다.
CLIPProcessor는 텍스트와 이미지를 모델이 이해할 수 있는 형태로 전처리하는 역할을 합니다. 텍스트는 토큰화되고, 이미지는 224x224 픽셀로 리사이즈되며 정규화됩니다.
ViT-base-patch32 모델은 Vision Transformer 구조로, 이미지를 32x32 픽셀 패치로 나누어 처리합니다. 그 다음으로, embed_text와 embed_image 함수가 각각 텍스트와 이미지를 벡터로 변환합니다.
중요한 것은 L2 정규화입니다. 벡터의 노름(길이)을 1로 만들면, 두 벡터의 내적이 코사인 유사도와 동일해집니다.
이렇게 정규화하지 않으면 벡터의 크기가 유사도 계산에 영향을 주어 부정확한 결과가 나올 수 있습니다. 마지막으로, compute_similarity 함수가 텍스트 임베딩과 이미지 임베딩의 내적을 계산합니다.
결과는 -1에서 1 사이의 값인데, 실제로는 대부분 0에서 1 사이에 분포합니다. 0.8 이상이면 매우 유사, 0.5~0.8이면 중간 정도, 0.5 미만이면 관련 없음으로 판단할 수 있습니다.
예를 들어, "빨간 스포츠카" 텍스트와 실제 빨간 페라리 이미지는 0.85 정도의 높은 유사도를 보일 것입니다. 여러분이 이 코드를 사용하면 전자상거래 사이트에서 사용자가 "파란색 청바지"라고 검색했을 때, 실제로 파란 청바지 이미지들을 유사도 순으로 정렬하여 보여줄 수 있습니다.
의료 문서 검색에서 "뇌 MRI 영상"이라고 검색하면 관련 MRI 이미지와 설명을 함께 찾을 수 있습니다. 가장 큰 장점은 이미지에 태그를 달지 않아도 자동으로 검색이 가능하다는 점입니다.
실전 팁
💡 CLIP은 영어로 학습되었으므로 한국어 쿼리는 정확도가 떨어집니다. multilingual-clip 같은 다국어 모델을 사용하거나, 쿼리를 영어로 번역 후 검색하세요.
💡 배치 처리로 성능을 대폭 개선할 수 있습니다. 이미지 100개를 하나씩 임베딩하면 10초 걸리지만, 배치로 한 번에 처리하면 2초면 됩니다. processor에 리스트를 넘기세요.
💡 GPU가 있다면 반드시 사용하세요. model.to("cuda")로 모델을 GPU에 올리면 임베딩 속도가 10배 이상 빨라집니다. CPU로는 실시간 검색이 어렵습니다.
💡 임베딩 벡터는 한 번 계산하면 재사용하세요. 미리 계산하여 벡터 DB에 저장해두고, 검색 시에는 쿼리만 임베딩하면 됩니다. 이렇게 하면 응답 속도가 100배 빨라집니다.
💡 도메인 특화 검색이 필요하면 파인튜닝을 고려하세요. 의료 이미지, 패션 아이템 등 특정 도메인 데이터로 추가 학습하면 정확도가 20~30% 향상됩니다.
4. 멀티모달 벡터 저장소
시작하며
여러분이 텍스트와 이미지를 모두 임베딩했다면, 이제 어디에 어떻게 저장할까요? 기존 벡터 DB는 대부분 텍스트 임베딩만 고려하여 설계되었습니다.
이미지 바이너리를 함께 저장하거나, 텍스트-이미지 간 관계를 표현하거나, 멀티모달 쿼리를 처리하는 기능이 없었습니다. 단순히 이미지 URL만 메타데이터에 넣으면 검색은 되지만, 이미지 자체로는 검색할 수 없습니다.
별도의 이미지 DB를 만들면 텍스트-이미지 통합 검색이 불가능합니다. 실제로 많은 팀이 이 단계에서 복잡한 커스텀 솔루션을 만들어야 했습니다.
바로 이럴 때 필요한 것이 멀티모달 벡터 저장소입니다. 텍스트와 이미지 임베딩을 동일한 컬렉션에 저장하면서도 각각의 원본 데이터와 메타데이터를 함께 관리하고, 하나의 쿼리로 둘 다 검색할 수 있게 해줍니다.
개요
간단히 말해서, 멀티모달 벡터 저장소는 텍스트와 이미지 임베딩을 하나의 통합된 인덱스에 저장하고, 의미적 유사도 기반으로 검색할 수 있게 해주는 데이터베이스입니다. 이것이 왜 중요하냐면, 사용자가 "제품 설치 방법"을 검색했을 때 텍스트 설명과 설치 다이어그램을 함께 반환해야 하기 때문입니다.
예를 들어, 가구 조립 매뉴얼에서 "3단계"를 검색하면 텍스트 지시사항과 함께 해당 단계의 그림도 나와야 합니다. 이를 위해서는 두 가지 데이터 타입이 같은 벡터 공간에서 인덱싱되어야 합니다.
기존에는 Pinecone이나 Weaviate에 텍스트만 저장하고 이미지는 S3 URL을 메타데이터에 넣었다면, 이제는 이미지 임베딩도 직접 저장하여 이미지 자체로 검색이 가능합니다. 핵심 구조는 세 가지입니다.
첫째, 통합 임베딩 공간에 텍스트와 이미지 벡터를 모두 저장합니다. 둘째, 메타데이터로 타입(text/image), 원본 위치, 관계 정보를 기록합니다.
셋째, 필터링 기능으로 "텍스트만", "이미지만", 또는 "둘 다" 검색할 수 있게 합니다. 이러한 구조가 유연하면서도 효율적인 멀티모달 검색을 가능하게 합니다.
코드 예제
from langchain_community.vectorstores import Chroma
from langchain.embeddings.base import Embeddings
import chromadb
from chromadb.config import Settings
import base64
from io import BytesIO
class MultimodalChromaDB:
def __init__(self, collection_name: str, embedding_function: Embeddings):
"""텍스트와 이미지를 함께 저장하는 벡터 DB"""
self.client = chromadb.Client(Settings(
persist_directory="./multimodal_db",
anonymized_telemetry=False
))
self.collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"} # 코사인 유사도 사용
)
self.embedding_function = embedding_function
def add_texts(self, texts: list, metadatas: list = None):
"""텍스트 문서 추가"""
embeddings = [self.embedding_function.embed_text(t) for t in texts]
ids = [f"text_{i}" for i in range(len(texts))]
# 메타데이터에 타입 명시
if metadatas is None:
metadatas = [{}] * len(texts)
for meta in metadatas:
meta["type"] = "text"
self.collection.add(
embeddings=embeddings,
documents=texts,
metadatas=metadatas,
ids=ids
)
def add_images(self, images: list, metadatas: list = None):
"""이미지 추가 - base64로 인코딩하여 저장"""
embeddings = [self.embedding_function.embed_image(img) for img in images]
ids = [f"image_{i}" for i in range(len(images))]
# 이미지를 base64로 인코딩
encoded_images = []
for img in images:
buffered = BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
encoded_images.append(img_str)
if metadatas is None:
metadatas = [{}] * len(images)
for meta in metadatas:
meta["type"] = "image"
self.collection.add(
embeddings=embeddings,
documents=encoded_images, # base64 문자열 저장
metadatas=metadatas,
ids=ids
)
def search(self, query: str, n_results: int = 5, filter_type: str = None):
"""통합 검색 - 텍스트 쿼리로 텍스트와 이미지 모두 검색"""
query_embedding = self.embedding_function.embed_text(query)
# 타입 필터링 옵션
where_filter = {"type": filter_type} if filter_type else None
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=n_results,
where=where_filter
)
return results
설명
이것이 하는 일: 이 코드는 ChromaDB를 사용하여 텍스트와 이미지 임베딩을 하나의 컬렉션에 저장하고, 단일 쿼리로 두 타입을 모두 검색할 수 있는 시스템을 구축합니다. 첫 번째로, ChromaDB 클라이언트를 초기화하고 컬렉션을 생성합니다.
hnsw:space: cosine 설정은 코사인 유사도를 거리 메트릭으로 사용하겠다는 의미입니다. HNSW(Hierarchical Navigable Small World)는 고성능 근사 최근접 이웃 검색 알고리즘으로, 수백만 개의 벡터에서도 밀리초 단위로 검색이 가능합니다.
persist_directory를 지정하면 디스크에 저장되어 재시작해도 데이터가 유지됩니다. 그 다음으로, add_texts와 add_images 메서드가 각각 텍스트와 이미지를 저장합니다.
중요한 것은 메타데이터에 "type" 필드를 추가하는 것입니다. 이를 통해 나중에 "텍스트만 검색" 또는 "이미지만 검색" 같은 필터링이 가능해집니다.
이미지는 base64로 인코딩하여 문자열로 변환 후 저장합니다. 왜냐하면 ChromaDB의 documents 필드는 문자열만 받기 때문입니다.
원본 이미지를 복원하려면 나중에 base64 디코딩을 하면 됩니다. 마지막으로, search 메서드가 텍스트 쿼리를 받아서 임베딩한 후, 벡터 공간에서 가장 유사한 아이템들을 찾습니다.
filter_type 파라미터로 "text" 또는 "image"를 지정하면 해당 타입만 검색하고, None이면 둘 다 검색합니다. 예를 들어, "심장 구조"로 검색하면 심장 설명 텍스트와 심장 다이어그램 이미지가 모두 유사도 순으로 반환됩니다.
여러분이 이 코드를 사용하면 기술 문서 1000페이지를 인덱싱한 후, "네트워크 토폴로지"라고 검색했을 때 관련 텍스트 설명 3개와 다이어그램 이미지 2개를 함께 얻을 수 있습니다. 검색 결과가 다양한 형태로 제공되므로 사용자가 원하는 정보를 더 쉽게 찾을 수 있습니다.
또한 모든 데이터가 하나의 인덱스에 있어서 관리가 단순하고 검색 속도도 빠릅니다.
실전 팁
💡 ChromaDB는 개발용으로는 좋지만 프로덕션에서는 Qdrant나 Weaviate를 고려하세요. 이들은 더 나은 확장성과 동시성 처리를 제공합니다.
💡 이미지를 base64로 저장하면 DB 크기가 커집니다. 대량의 이미지라면 S3에 저장하고 URL만 메타데이터에 넣는 하이브리드 방식을 사용하세요.
💡 컬렉션 크기가 100만 개를 넘으면 샤딩을 고려하세요. 문서 타입이나 날짜별로 컬렉션을 나누면 검색 속도를 유지할 수 있습니다.
💡 메타데이터에 timestamp를 추가하면 시간 기반 필터링이 가능합니다. "최근 1주일 내 이미지만"같은 검색을 구현할 수 있습니다.
💡 정기적으로 벡터 인덱스를 최적화하세요. ChromaDB는 persist() 호출 시 인덱스를 재구축하는데, 이를 주기적으로 하면 검색 속도가 빨라집니다.
5. 이미지 검색 최적화
시작하며
여러분이 멀티모달 벡터 DB를 구축하고 검색을 해봤는데, 결과가 기대만큼 정확하지 않다면 어떻게 해야 할까요? "빨간 드레스"를 검색했는데 파란 드레스가 상위에 나오거나, "시스템 아키텍처"를 검색했는데 관련 없는 UI 스크린샷이 나올 수 있습니다.
이런 문제는 단순히 벡터 유사도만으로 검색하기 때문에 발생합니다. CLIP 임베딩이 아무리 강력해도 100% 완벽하지는 않습니다.
색상, 스타일, 문맥 같은 세부 요소를 놓칠 수 있고, 때로는 시각적으로는 비슷하지만 의미는 다른 이미지를 반환하기도 합니다. 바로 이럴 때 필요한 것이 이미지 검색 최적화입니다.
재순위화(re-ranking), 메타데이터 필터링, 하이브리드 스코어링 등의 기법으로 검색 정확도를 크게 향상시킬 수 있습니다.
개요
간단히 말해서, 이미지 검색 최적화는 초기 벡터 검색 결과를 다양한 기법으로 재평가하고 재정렬하여 사용자에게 가장 관련성 높은 결과를 제공하는 프로세스입니다. 이것이 왜 필요하냐면, 벡터 유사도만으로는 사용자의 진짜 의도를 완벽히 파악하기 어렵기 때문입니다.
예를 들어, 전자상거래에서 "여름 원피스"를 검색할 때 계절(여름), 카테고리(원피스), 스타일 등 여러 요소를 종합적으로 고려해야 합니다. 의료 이미지 검색에서는 해부학적 위치, 촬영 기법, 병변 유형 등의 메타데이터가 중요합니다.
기존에는 벡터 DB에서 top-K 결과를 그대로 반환했다면, 이제는 여러 단계의 필터링과 재순위화를 거쳐 정밀도를 높일 수 있습니다. 핵심 기법은 네 가지입니다.
첫째, 메타데이터 필터링으로 명확한 조건(날짜, 카테고리, 태그)을 미리 적용합니다. 둘째, Cross-encoder 재순위화로 쿼리와 결과를 더 정밀하게 비교합니다.
셋째, 멀티 벡터 검색으로 쿼리의 여러 측면을 각각 검색 후 결합합니다. 넷째, 사용자 피드백을 활용한 학습 기반 랭킹입니다.
이러한 기법들이 초기 검색 정확도를 30~50% 향상시킬 수 있습니다.
코드 예제
from sentence_transformers import CrossEncoder
import numpy as np
from typing import List, Tuple
class ImageSearchOptimizer:
def __init__(self):
# Cross-encoder: 쿼리-이미지 쌍을 직접 비교하는 정밀 모델
self.reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
def rerank_results(self, query: str, initial_results: List[dict], top_k: int = 5) -> List[dict]:
"""재순위화: 초기 검색 결과를 더 정밀하게 재평가"""
# 초기 결과가 충분히 많아야 재순위화 효과가 큼
candidates = initial_results[:top_k * 3] # 15개 후보에서 5개 선택
# 쿼리와 각 결과의 텍스트 설명을 쌍으로 만듦
pairs = [(query, result.get('description', '')) for result in candidates]
# Cross-encoder로 정밀 스코어 계산
scores = self.reranker.predict(pairs)
# 스코어 순으로 재정렬
ranked_indices = np.argsort(scores)[::-1]
reranked_results = [candidates[i] for i in ranked_indices[:top_k]]
# 최종 스코어 추가
for result, score in zip(reranked_results, sorted(scores, reverse=True)[:top_k]):
result['rerank_score'] = float(score)
return reranked_results
def apply_metadata_filters(self, results: List[dict], filters: dict) -> List[dict]:
"""메타데이터 필터링: 명확한 조건으로 결과 좁히기"""
filtered = results
if 'date_range' in filters:
start, end = filters['date_range']
filtered = [r for r in filtered
if start <= r.get('date', '') <= end]
if 'category' in filters:
filtered = [r for r in filtered
if r.get('category') == filters['category']]
if 'min_resolution' in filters:
min_w, min_h = filters['min_resolution']
filtered = [r for r in filtered
if r.get('width', 0) >= min_w and r.get('height', 0) >= min_h]
return filtered
def hybrid_scoring(self, vector_score: float, metadata_score: float, alpha: float = 0.7) -> float:
"""하이브리드 스코어링: 벡터 유사도와 메타데이터 매칭을 결합"""
# alpha: 벡터 유사도의 가중치 (0.7 = 70% 벡터, 30% 메타데이터)
return alpha * vector_score + (1 - alpha) * metadata_score
def calculate_metadata_score(self, query_metadata: dict, result_metadata: dict) -> float:
"""메타데이터 유사도 계산"""
score = 0.0
total_weight = 0.0
# 카테고리 일치 (가중치 0.4)
if query_metadata.get('category') == result_metadata.get('category'):
score += 0.4
total_weight += 0.4
# 태그 일치도 (가중치 0.3)
query_tags = set(query_metadata.get('tags', []))
result_tags = set(result_metadata.get('tags', []))
if query_tags and result_tags:
tag_overlap = len(query_tags & result_tags) / len(query_tags | result_tags)
score += 0.3 * tag_overlap
total_weight += 0.3
# 날짜 근접도 (가중치 0.3)
# 최근 것일수록 높은 점수
if 'date' in result_metadata:
# 실제로는 날짜 차이를 계산
score += 0.3 * 0.8 # 예시
total_weight += 0.3
return score / total_weight if total_weight > 0 else 0.0
설명
이것이 하는 일: 이 코드는 초기 벡터 검색 결과를 Cross-encoder로 재평가하고, 메타데이터 필터링과 하이브리드 스코어링을 적용하여 최종적으로 가장 관련성 높은 결과를 선택합니다. 첫 번째로, rerank_results 메서드가 Cross-encoder를 사용하여 재순위화합니다.
Cross-encoder는 bi-encoder(CLIP 같은)보다 정확하지만 느립니다. 그래서 전략은 이렇습니다: bi-encoder로 빠르게 후보 15개를 찾고, 그 중에서 Cross-encoder로 정밀하게 최종 5개를 선택합니다.
Cross-encoder는 쿼리와 결과를 함께 입력받아 관련성 스코어를 직접 계산하므로, 미묘한 의미 차이도 잘 잡아냅니다. 그 다음으로, apply_metadata_filters가 명확한 조건들을 적용합니다.
예를 들어, "2023년 여름 컬렉션"을 검색한다면 날짜 범위와 카테고리 필터를 적용하여 관련 없는 결과를 미리 제거합니다. 이미지 해상도 필터는 "고화질 이미지만" 같은 요구사항에 유용합니다.
메타데이터 필터링을 먼저 적용하면 재순위화할 후보 수가 줄어들어 속도도 빨라집니다. 마지막으로, hybrid_scoring이 벡터 유사도와 메타데이터 매칭 점수를 결합합니다.
alpha 파라미터로 가중치를 조절할 수 있는데, 0.7이면 벡터 유사도를 70% 반영한다는 의미입니다. 메타데이터가 풍부한 도메인(전자상거래, 의료)에서는 alpha를 0.50.6으로 낮춰서 메타데이터 비중을 높이고, 일반 문서 검색에서는 0.80.9로 높여서 의미적 유사도를 중시합니다.
calculate_metadata_score는 카테고리, 태그, 날짜 등의 메타데이터 일치도를 0~1 사이 점수로 변환합니다. 여러분이 이 코드를 사용하면 전자상거래에서 "빨간 원피스"를 검색했을 때, 초기 검색에서 파란 원피스가 섞여 나왔더라도 재순위화 후에는 정확히 빨간색만 상위에 나옵니다.
의료 이미지 검색에서 "폐렴 X-ray"를 검색하면 다른 부위 X-ray는 필터링되고 폐 X-ray만 반환됩니다. 검색 정확도가 30~50% 향상되어 사용자 만족도가 크게 높아집니다.
실전 팁
💡 Cross-encoder 재순위화는 느리므로 후보 수를 제한하세요. Top-50 후보에서 Top-10을 선택하는 것이 적절합니다. 후보가 너무 많으면 응답 시간이 길어집니다.
💡 도메인에 맞는 Cross-encoder를 선택하세요. 전자상거래는 'cross-encoder/qnli-distilroberta-base', 의료는 BioBERT 기반 모델이 더 정확합니다.
💡 A/B 테스트로 alpha 값을 최적화하세요. 사용자 클릭률을 측정하여 0.5, 0.7, 0.9를 비교하면 도메인별 최적값을 찾을 수 있습니다.
💡 사용자 클릭 데이터를 수집하여 학습 기반 랭킹을 구축하세요. LightGBM이나 XGBoost로 벡터 유사도, 메타데이터, 사용자 행동을 종합한 모델을 만들면 정확도가 더 높아집니다.
💡 캐싱을 활용하세요. 인기 쿼리의 재순위화 결과를 Redis에 캐싱하면 응답 속도가 10배 빨라집니다. TTL을 1시간 정도로 설정하세요.
6. GPT-4V를 활용한 응답 생성
시작하며
여러분이 이미지와 텍스트를 성공적으로 검색했다면, 이제 이를 사용자에게 어떻게 제시할까요? 단순히 검색 결과 리스트를 보여주는 것이 아니라, "질문에 대한 통합된 답변"을 생성해야 합니다.
특히 이미지가 포함된 경우, 이미지의 내용을 이해하고 텍스트 설명과 결합하여 답변해야 합니다. 전통적인 LLM은 텍스트만 처리할 수 있어서, 이미지는 "image_1.png 참조"같은 식으로만 언급할 수 있었습니다.
사용자가 "이 다이어그램에서 어떤 부분이 중요한가요?"라고 물어도 LLM은 이미지를 볼 수 없어서 답변하지 못했습니다. 바로 이럴 때 필요한 것이 GPT-4V(Vision) 같은 멀티모달 LLM입니다.
이미지와 텍스트를 함께 입력받아 시각적 정보를 이해하고, 이를 바탕으로 풍부한 답변을 생성할 수 있습니다.
개요
간단히 말해서, GPT-4V를 활용한 응답 생성은 검색된 텍스트와 이미지를 멀티모달 LLM에 함께 전달하여, 시각적 정보와 텍스트 정보를 모두 이해한 종합적인 답변을 만드는 과정입니다. 이것이 왜 강력하냐면, 사용자가 복잡한 질문을 해도 이미지의 세부 내용까지 참조하여 정확하게 답변할 수 있기 때문입니다.
예를 들어, "이 아키텍처에서 병목 지점이 어디인가요?"라고 물으면 GPT-4V가 다이어그램을 분석하여 "데이터베이스 레이어가 병목입니다. 다이어그램에서 화살표가 집중된 부분을 보면..."이라고 구체적으로 답변합니다.
기존에는 검색된 텍스트만 LLM에 넣고 이미지는 URL만 제공했다면, 이제는 이미지 자체를 LLM이 "보고" 이해할 수 있습니다. 핵심 워크플로우는 네 단계입니다.
첫째, 멀티모달 검색으로 관련 텍스트와 이미지를 찾습니다. 둘째, 이미지를 base64로 인코딩하여 GPT-4V에 전달 가능한 형태로 준비합니다.
셋째, 프롬프트에 질문, 텍스트 컨텍스트, 이미지를 모두 포함시킵니다. 넷째, GPT-4V가 시각적 정보와 텍스트를 종합하여 답변을 생성합니다.
이러한 워크플로우가 사용자에게 진정한 멀티모달 AI 경험을 제공합니다.
코드 예제
from openai import OpenAI
import base64
from io import BytesIO
from PIL import Image
client = OpenAI()
def generate_multimodal_response(query: str, retrieved_texts: list, retrieved_images: list) -> str:
"""GPT-4V로 텍스트와 이미지를 모두 활용한 답변 생성"""
# 이미지를 base64로 인코딩
image_contents = []
for img in retrieved_images[:3]: # 최대 3개 이미지 사용 (토큰 비용 고려)
buffered = BytesIO()
img.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
image_contents.append({
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_base64}",
"detail": "high" # 고해상도 분석
}
})
# 텍스트 컨텍스트 준비
text_context = "\n\n".join([f"[문서 {i+1}] {text}" for i, text in enumerate(retrieved_texts[:5])])
# 멀티모달 프롬프트 구성
messages = [
{
"role": "system",
"content": "당신은 텍스트와 이미지를 함께 분석하여 정확한 답변을 제공하는 AI 어시스턴트입니다."
},
{
"role": "user",
"content": [
{
"type": "text",
"text": f"""질문: {query}
관련 문서:
{text_context}
위 텍스트와 함께 제공된 이미지들을 분석하여 질문에 답변해주세요.
이미지의 구체적인 내용을 언급하며 설명해주세요."""
},
*image_contents # 이미지들 추가
]
}
]
# GPT-4V 호출
response = client.chat.completions.create(
model="gpt-4o", # GPT-4 Omni (vision 포함)
messages=messages,
max_tokens=1000,
temperature=0.3 # 낮은 온도로 정확성 우선
)
return response.choices[0].message.content
# 사용 예시
query = "시스템 아키텍처에서 데이터 흐름을 설명해주세요"
retrieved_texts = ["마이크로서비스 아키텍처는...", "API 게이트웨이를 통해..."]
retrieved_images = [Image.open("architecture_diagram.png")]
answer = generate_multimodal_response(query, retrieved_texts, retrieved_images)
print(answer)
설명
이것이 하는 일: 이 코드는 검색된 텍스트와 이미지를 GPT-4V에 함께 전달하여, 시각적 정보를 이해하고 텍스트와 결합한 종합적인 답변을 생성합니다. 첫 번째로, 이미지를 base64 문자열로 인코딩합니다.
GPT-4V는 이미지를 URL 또는 base64 형식으로 받을 수 있는데, base64를 사용하면 외부 서버 없이 직접 전달할 수 있습니다. "data:image/png;base64,..." 형식의 Data URL을 만듭니다.
detail="high" 옵션은 이미지를 고해상도로 분석하겠다는 의미로, 더 정확하지만 토큰을 더 많이 소비합니다. 비용을 고려하여 최대 3개 이미지로 제한합니다.
그 다음으로, 텍스트 컨텍스트를 준비합니다. 검색된 텍스트 문서들을 번호를 붙여 정리하면, GPT-4V가 답변할 때 "문서 2에 따르면..."같이 출처를 명시할 수 있습니다.
최대 5개 문서로 제한하여 컨텍스트 윈도우를 효율적으로 사용합니다. 마지막으로, 멀티모달 프롬프트를 구성합니다.
content 필드에 텍스트와 이미지를 리스트로 넣으면, GPT-4V가 순서대로 처리합니다. 시스템 프롬프트에서 역할을 명시하고, 사용자 프롬프트에서 질문과 컨텍스트를 제공한 후, "이미지의 구체적인 내용을 언급하며 설명해주세요"라고 지시합니다.
이렇게 하면 GPT-4V가 이미지를 단순히 참조만 하지 않고 실제로 분석하여 답변에 포함시킵니다. temperature=0.3으로 설정하여 창의적인 답변보다는 정확한 분석을 우선합니다.
여러분이 이 코드를 사용하면 기술 문서에서 "이 다이어그램의 컴포넌트들은 어떻게 상호작용하나요?"라고 질문했을 때, GPT-4V가 다이어그램을 실제로 분석하여 "왼쪽의 API Gateway가 중앙의 Service Mesh를 통해 오른쪽의 마이크로서비스들과 통신합니다. 화살표 방향을 보면..."같이 구체적으로 답변합니다.
의료 분야에서는 "이 X-ray에서 이상 소견이 보이나요?"라는 질문에 이미지를 분석하여 답변할 수 있습니다. 사용자가 텍스트만으로는 얻을 수 없는 시각적 인사이트를 얻게 됩니다.
실전 팁
💡 GPT-4V는 비용이 높으므로 이미지 개수를 제한하세요. 텍스트 토큰 외에 이미지당 추가 비용이 발생합니다. 3개 이하가 적절합니다.
💡 detail 옵션을 조절하여 비용과 정확도를 조절하세요. "low"는 저해상도로 빠르고 저렴하며, "high"는 고해상도로 정확하지만 비쌉니다. 간단한 이미지는 "low"로 충분합니다.
💡 이미지 크기를 최적화하세요. 너무 큰 이미지는 리사이즈하여 전송 시간과 비용을 줄이세요. 대부분의 경우 1024x1024 정도면 충분합니다.
💡 프롬프트에서 이미지 분석을 명시적으로 요청하세요. "이미지를 자세히 분석하여"라는 지시를 넣으면 더 구체적인 답변을 얻을 수 있습니다.
💡 답변 품질을 높이려면 few-shot 예시를 추가하세요. "이미지 분석 예시"를 시스템 프롬프트에 넣으면 일관된 답변 형식을 유도할 수 있습니다.
7. 표와 차트 처리
시작하며
여러분이 비즈니스 보고서나 연구 논문을 RAG 시스템에 넣을 때, 가장 중요한 정보가 어디에 있나요? 바로 표와 차트입니다.
"2023년 분기별 매출"이나 "실험 결과 비교"같은 핵심 데이터는 대부분 표 형태로 제공됩니다. 차트는 트렌드와 패턴을 시각적으로 보여줍니다.
하지만 전통적인 RAG는 표를 제대로 처리하지 못합니다. PDF에서 텍스트를 추출하면 표 구조가 깨지고, 열과 행의 관계가 사라집니다.
"3분기 매출이 얼마인가요?"라고 물어도 표가 깨져서 LLM이 답변하지 못합니다. 차트 이미지는 그냥 이미지로만 인식되어 내부 데이터를 활용할 수 없습니다.
바로 이럴 때 필요한 것이 표와 차트의 구조화된 처리입니다. 표는 데이터베이스처럼 쿼리 가능한 형태로 변환하고, 차트는 Vision 모델로 데이터를 추출하여 텍스트화합니다.
개요
간단히 말해서, 표와 차트 처리는 문서 내 구조화된 데이터를 추출하고, 쿼리 가능하고 LLM이 이해할 수 있는 형태로 변환하는 프로세스입니다. 이것이 왜 중요하냐면, 비즈니스와 과학 문서의 핵심 정보 대부분이 표와 차트에 담겨 있기 때문입니다.
예를 들어, 재무 보고서에서 "전년 대비 성장률"을 찾으려면 비교 표를 분석해야 합니다. 연구 논문에서 "실험 그룹별 결과"를 비교하려면 결과 표를 읽어야 합니다.
이런 데이터를 텍스트로만 변환하면 구조가 손실되고, 이미지로만 저장하면 쿼리가 불가능합니다. 기존에는 표를 무시하거나 깨진 텍스트로 저장했다면, 이제는 CSV나 JSON 같은 구조화된 형태로 변환하여 활용할 수 있습니다.
핵심 전략은 세 가지입니다. 첫째, 표 감지 및 추출 도구(Camelot, Tabula)로 표 구조를 보존하며 추출합니다.
둘째, 추출된 표를 마크다운이나 JSON으로 변환하여 LLM이 이해하기 쉽게 만듭니다. 셋째, GPT-4V로 차트 이미지를 분석하여 데이터 포인트와 트렌드를 텍스트로 추출합니다.
이러한 전략들이 구조화된 데이터를 RAG 시스템에서 완전히 활용 가능하게 만들어줍니다.
코드 예제
import camelot
import pandas as pd
from openai import OpenAI
import base64
from io import BytesIO
client = OpenAI()
def extract_tables_from_pdf(pdf_path: str, page_number: int) -> list:
"""PDF에서 표를 구조 보존하며 추출"""
# Camelot으로 표 추출 (선 기반 표에 강함)
tables = camelot.read_pdf(pdf_path, pages=str(page_number), flavor='lattice')
extracted_tables = []
for table in tables:
# DataFrame으로 변환
df = table.df
# 마크다운 형식으로 변환 (LLM이 이해하기 쉬움)
markdown_table = df.to_markdown(index=False)
# JSON 형식으로도 변환 (프로그래밍적 접근 가능)
json_table = df.to_dict('records')
extracted_tables.append({
'markdown': markdown_table,
'json': json_table,
'dataframe': df,
'page': page_number
})
return extracted_tables
def analyze_chart_with_gpt4v(chart_image) -> str:
"""GPT-4V로 차트 이미지에서 데이터와 인사이트 추출"""
# 이미지를 base64로 인코딩
buffered = BytesIO()
chart_image.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
# GPT-4V로 차트 분석
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": """이 차트를 분석하여 다음 정보를 추출해주세요:
5. 핵심 인사이트
설명
이것이 하는 일: 이 코드는 PDF에서 표를 구조를 유지하며 추출하고, 차트 이미지를 GPT-4V로 분석하여 데이터와 인사이트를 텍스트로 변환합니다. 첫 번째로, extract_tables_from_pdf가 Camelot 라이브러리를 사용하여 표를 추출합니다.
Camelot은 PDF의 시각적 레이아웃을 분석하여 표 경계를 감지합니다. flavor='lattice'는 선으로 구분된 표를 처리하는 모드입니다(선이 없는 표는 flavor='stream' 사용).
추출된 표는 pandas DataFrame으로 변환되어 강력한 데이터 조작이 가능합니다. DataFrame을 마크다운으로 변환하면 LLM이 읽기 쉽고, JSON으로 변환하면 프로그래밍적으로 쿼리할 수 있습니다.
그 다음으로, analyze_chart_with_gpt4v가 차트 이미지를 GPT-4V에 전달하여 분석합니다. 차트는 픽셀 이미지이지만, GPT-4V가 시각적 패턴을 이해하여 "2020년부터 2023년까지 매출이 지속적으로 증가했으며, 2022년에 가장 큰 성장률을 보였습니다"같은 텍스트로 변환합니다.
축 레이블, 데이터 포인트, 트렌드를 모두 추출하여 구조화된 텍스트로 만듭니다. 이 텍스트는 나중에 임베딩되어 검색 가능해집니다.
마지막으로, prepare_table_for_rag가 표 데이터를 RAG 시스템에 최적화된 형태로 변환합니다. 표 위아래의 캡션이나 설명 텍스트를 context로 추가하면 표의 의미를 더 잘 이해할 수 있습니다.
예를 들어, "분기별 매출 현황"이라는 캡션이 있으면 LLM이 표의 목적을 파악하여 더 정확하게 답변합니다. 행과 열 개수 정보도 추가하여 표의 규모를 명시합니다.
여러분이 이 코드를 사용하면 재무 보고서에서 "2023년 3분기 매출이 얼마인가요?"라고 질문했을 때, 표에서 정확한 값을 찾아 답변할 수 있습니다. 연구 논문에서 "실험 그룹 A와 B의 결과 차이는?"이라고 물으면 결과 표를 분석하여 비교해줍니다.
차트 이미지에 대해 "매출 트렌드를 설명해줘"라고 하면 GPT-4V가 차트를 분석하여 "2020년 100만 달러에서 2023년 500만 달러로 5배 성장했으며, 특히 2022년에 급증했습니다"같이 구체적으로 답변합니다.
실전 팁
💡 Camelot은 선 기반 표(lattice)와 공백 기반 표(stream) 두 모드가 있습니다. 표가 제대로 추출되지 않으면 다른 모드를 시도하세요.
💡 복잡한 표는 전처리가 필요합니다. 병합된 셀이나 다단 헤더는 수동으로 정리하거나 GPT-4V로 이미지를 분석하여 구조를 파악하세요.
💡 표를 임베딩할 때는 캡션과 주변 텍스트를 반드시 포함하세요. 표 자체만으로는 의미가 불명확할 수 있습니다.
💡 대용량 표는 요약하여 저장하세요. 100행 이상의 표는 통계 요약(평균, 최대, 최소 등)을 추가하고 원본은 별도 저장하여 필요시 참조하세요.
💡 차트 분석 비용을 줄이려면 중요한 차트만 GPT-4V로 분석하세요. 이미지 크기나 캡션으로 중요도를 판단하여 필터링하면 비용을 50% 줄일 수 있습니다.
8. 하이브리드 검색 전략
시작하며
여러분이 지금까지 배운 모든 기술을 실전에 적용할 때, 어떻게 최적의 결과를 얻을 수 있을까요? 텍스트만 검색하면 이미지를 놓치고, 이미지만 검색하면 설명을 놓칩니다.
벡터 검색만 하면 정확한 키워드 매칭을 놓치고, 키워드 검색만 하면 의미적 유사성을 놓칩니다. 실제 사용자 질문은 복잡합니다.
"2023년 신제품 출시 일정과 마케팅 자료를 보여줘"라는 질문은 날짜 필터링(키워드), 의미 검색(신제품), 이미지 검색(마케팅 자료)을 모두 필요로 합니다. 단일 검색 방법으로는 모든 요구사항을 충족할 수 없습니다.
바로 이럴 때 필요한 것이 하이브리드 검색 전략입니다. 벡터 검색, 키워드 검색, 메타데이터 필터링, 텍스트-이미지 통합 검색을 조합하여 최고의 정확도와 재현율을 달성합니다.
개요
간단히 말해서, 하이브리드 검색 전략은 여러 검색 방법을 조합하고 결과를 지능적으로 병합하여, 단일 방법으로는 불가능한 높은 검색 품질을 달성하는 기법입니다. 이것이 왜 필수적이냐면, 각 검색 방법은 장단점이 있어서 서로 보완할 때 최고의 성능을 발휘하기 때문입니다.
예를 들어, 벡터 검색은 의미적 유사성에 강하지만 정확한 키워드 매칭에는 약합니다. BM25 키워드 검색은 정확한 용어 매칭에 강하지만 동의어나 관련 개념을 놓칩니다.
둘을 결합하면 양쪽의 강점을 모두 얻습니다. 기존에는 벡터 검색만 사용하거나 키워드 검색만 사용했다면, 이제는 Reciprocal Rank Fusion(RRF) 같은 알고리즘으로 여러 검색 결과를 최적으로 병합할 수 있습니다.
핵심 구성요소는 네 가지입니다. 첫째, 벡터 검색으로 의미적으로 유사한 콘텐츠를 찾습니다.
둘째, BM25 키워드 검색으로 정확한 용어 매칭을 수행합니다. 셋째, 메타데이터 필터로 날짜, 카테고리 등 명확한 조건을 적용합니다.
넷째, RRF나 가중 평균으로 결과를 병합합니다. 이러한 구성요소들이 어떤 종류의 질문에도 최적의 답변을 제공할 수 있게 만들어줍니다.
코드 예제
from rank_bm25 import BM25Okapi
import numpy as np
from typing import List, Dict
from collections import defaultdict
class HybridMultimodalSearch:
def __init__(self, vectorstore, documents):
self.vectorstore = vectorstore
self.documents = documents
# BM25 인덱스 구축 (키워드 검색용)
tokenized_docs = [doc['text'].split() for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
def vector_search(self, query: str, top_k: int = 20) -> List[Dict]:
"""벡터 유사도 검색"""
results = self.vectorstore.search(query, n_results=top_k)
return [{"id": r['id'], "score": r['score'], "content": r} for r in results]
def keyword_search(self, query: str, top_k: int = 20) -> List[Dict]:
"""BM25 키워드 검색"""
tokenized_query = query.split()
scores = self.bm25.get_scores(tokenized_query)
# 상위 K개 선택
top_indices = np.argsort(scores)[::-1][:top_k]
return [{"id": i, "score": scores[i], "content": self.documents[i]} for i in top_indices]
def reciprocal_rank_fusion(self, rankings: List[List[Dict]], k: int = 60) -> List[Dict]:
"""Reciprocal Rank Fusion으로 여러 검색 결과 병합"""
# RRF 점수 계산: 1 / (k + rank)
rrf_scores = defaultdict(float)
doc_contents = {}
for ranking in rankings:
for rank, doc in enumerate(ranking, start=1):
doc_id = doc['id']
rrf_scores[doc_id] += 1.0 / (k + rank)
doc_contents[doc_id] = doc['content']
# RRF 점수 순으로 정렬
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return [{"id": doc_id, "rrf_score": score, "content": doc_contents[doc_id]}
for doc_id, score in sorted_docs]
def hybrid_search(self, query: str, filters: Dict = None, top_k: int = 10) -> List[Dict]:
"""하이브리드 멀티모달 검색"""
# 1. 벡터 검색 (텍스트 + 이미지)
vector_results = self.vector_search(query, top_k=20)
# 2. 키워드 검색 (텍스트만)
keyword_results = self.keyword_search(query, top_k=20)
# 3. RRF로 결과 병합
merged_results = self.reciprocal_rank_fusion([vector_results, keyword_results])
# 4. 메타데이터 필터링 적용
if filters:
merged_results = self._apply_filters(merged_results, filters)
# 5. 타입별 다양성 보장 (텍스트와 이미지 골고루)
diverse_results = self._ensure_diversity(merged_results, top_k)
return diverse_results[:top_k]
def _apply_filters(self, results: List[Dict], filters: Dict) -> List[Dict]:
"""메타데이터 필터 적용"""
filtered = results
if 'date_range' in filters:
start, end = filters['date_range']
filtered = [r for r in filtered
if start <= r['content'].get('date', '') <= end]
if 'type' in filters: # 'text' 또는 'image'
filtered = [r for r in filtered
if r['content'].get('type') == filters['type']]
return filtered
def _ensure_diversity(self, results: List[Dict], top_k: int) -> List[Dict]:
"""결과에 텍스트와 이미지를 골고루 포함"""
texts = [r for r in results if r['content'].get('type') == 'text']
images = [r for r in results if r['content'].get('type') == 'image']
# 70% 텍스트, 30% 이미지 비율 유지
n_texts = int(top_k * 0.7)
n_images = top_k - n_texts
diverse = texts[:n_texts] + images[:n_images]
# RRF 점수로 재정렬
diverse.sort(key=lambda x: x['rrf_score'], reverse=True)
return diverse
설명
이것이 하는 일: 이 코드는 벡터 검색과 BM25 키워드 검색을 병행 수행하고, Reciprocal Rank Fusion으로 결과를 병합한 후, 메타데이터 필터링과 다양성 보장을 적용하여 최종 결과를 생성합니다. 첫 번째로, vector_search와 keyword_search가 각각 독립적으로 검색을 수행합니다.
벡터 검색은 "클라우드 인프라"와 "AWS 아키텍처"를 유사한 것으로 인식하지만, 키워드 검색은 정확히 "AWS"라는 단어가 있는 문서를 찾습니다. 둘 다 top-20 결과를 반환하여 충분한 후보를 확보합니다.
이렇게 하는 이유는 병합 후 재순위화하면 더 나은 최종 결과를 얻을 수 있기 때문입니다. 그 다음으로, reciprocal_rank_fusion이 두 검색 결과를 병합합니다.
RRF는 간단하지만 강력한 알고리즘입니다. 각 문서의 순위(rank)를 역수로 변환하여 점수를 계산합니다.
1등은 1/(60+1), 2등은 1/(60+2) 점수를 받습니다. 여러 검색 방법에서 높은 순위를 받은 문서는 높은 RRF 점수를 받게 됩니다.
이 방법의 장점은 각 검색 방법의 절대 점수에 의존하지 않아서 서로 다른 스케일의 점수를 공정하게 비교할 수 있다는 것입니다. 마지막으로, hybrid_search가 전체 프로세스를 조율합니다.
먼저 벡터와 키워드 검색을 병행 수행하고, RRF로 병합한 후, 메타데이터 필터를 적용합니다. _ensure_diversity 함수는 최종 결과에 텍스트와 이미지가 골고루 포함되도록 합니다.
예를 들어, top-10 결과라면 텍스트 7개, 이미지 3개를 포함시킵니다. 이렇게 하면 사용자가 다양한 형태의 정보를 얻을 수 있어 만족도가 높아집니다.
여러분이 이 코드를 사용하면 "쿠버네티스 배포 전략"을 검색했을 때, 벡터 검색으로 "컨테이너 오케스트레이션"같은 관련 개념 문서를 찾고, 키워드 검색으로 정확히 "Kubernetes"라는 용어가 있는 문서를 찾아, 둘을 병합하여 최적의 결과를 제공합니다. "2023년 신제품"이라는 쿼리에는 날짜 필터를 적용하여 2023년 문서만 반환합니다.
검색 정확도와 재현율이 모두 향상되어, 단일 검색 방법 대비 30~40% 더 나은 성능을 보입니다.
실전 팁
💡 RRF의 k 파라미터를 조정하여 순위의 영향력을 조절하세요. k=60이 일반적이지만, k를 낮추면 상위 결과의 영향력이 커지고, 높이면 하위 결과도 고려됩니다.
💡 검색 방법의 가중치를 조절할 수 있습니다. 도메인에 따라 벡터 검색을 더 중시하려면 벡터 결과를 2배로 카운트하세요.
💡 쿼리 분석으로 검색 전략을 동적으로 선택하세요. 날짜나 숫자가 포함된 쿼리는 키워드 검색 비중을 높이고, 추상적인 개념 쿼리는 벡터 검색 비중을 높이세요.
💡 A/B 테스트로 최적 비율을 찾으세요. 텍스트:이미지 비율, 벡터:키워드 가중치 등을 실험하여 사용자 만족도가 가장 높은 설정을 찾으세요.
💡 캐싱을 적극 활용하세요. 인기 쿼리의 하이브리드 검색 결과를 캐싱하면 응답 속도가 10배 빨라집니다. Redis에 1시간 TTL로 저장하세요.