CLIP 기반 멀티모달 임베딩의 원리, 텍스트-이미지 크로스모달 검색, 통합 벡터 스토어 설계, 그리고 실전 멀티모달 검색 시스템 구축을 다룹니다.
6장에서 비디오 이해를 다뤘습니다. 이 장에서는 멀티모달 데이터를 벡터 공간에서 통합적으로 표현하고 검색하는 기법을 다룹니다. 텍스트로 이미지를 검색하거나, 이미지로 유사한 텍스트를 찾는 크로스모달 검색은 멀티모달 RAG(8장)의 핵심 기반입니다.
멀티모달 임베딩의 핵심은 서로 다른 모달리티의 데이터를 같은 벡터 공간에 매핑하는 것입니다.
텍스트: "노을이 지는 해변" → [Text Encoder] → [0.12, -0.34, 0.56, ...]
이미지: 🌅 해변 사진 → [Image Encoder] → [0.11, -0.32, 0.58, ...]
↑ 가까운 벡터 = 의미적 유사
텍스트: "소스 코드 리뷰" → [Text Encoder] → [-0.45, 0.67, -0.12, ...]
↑ 먼 벡터 = 의미적 상이
이렇게 같은 공간에 매핑되면, 텍스트 벡터와 이미지 벡터 간의 코사인 유사도로 의미적 관련성을 측정할 수 있습니다.
| 모델 | 차원 | 모달리티 | 특징 |
|---|---|---|---|
| OpenAI CLIP | 512/768 | 텍스트, 이미지 | 범용, 대조 학습 |
| SigLIP | 256~1152 | 텍스트, 이미지 | Sigmoid 손실, 효율적 |
| OpenAI Embeddings v3 | 256~3072 | 텍스트 | 텍스트 전용이지만 고성능 |
| Cohere Embed v3 | 1024 | 텍스트, 이미지 | 다국어, 멀티모달 |
| Voyage Multimodal | 1024 | 텍스트, 이미지 | 문서+이미지 통합 |
| Jina CLIP v2 | 768 | 텍스트, 이미지 | 다국어, 긴 텍스트 |
import torch
from PIL import Image
from transformers import CLIPProcessor, CLIPModel
model = CLIPModel.from_pretrained("openai/clip-vit-large-patch14")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-large-patch14")
# 텍스트 임베딩
texts = ["해변의 석양", "도시의 야경", "숲 속의 오솔길"]
text_inputs = processor(text=texts, return_tensors="pt", padding=True)
with torch.no_grad():
text_features = model.get_text_features(**text_inputs)
text_features = text_features / text_features.norm(dim=-1, keepdim=True)
# 이미지 임베딩
image = Image.open("sunset_beach.jpg")
image_inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
image_features = model.get_image_features(**image_inputs)
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
# 유사도 계산
similarities = (text_features @ image_features.T).squeeze()
for text, sim in zip(texts, similarities):
print(f"{text}: {sim.item():.4f}")import cohere
import base64
co = cohere.Client(api_key="...")
# 텍스트와 이미지를 같은 공간에 임베딩
response = co.embed(
model="embed-v4.0",
input_type="search_document",
texts=["해변의 석양 사진"],
images=[base64.standard_b64encode(open("sunset.jpg", "rb").read()).decode()],
)
text_embedding = response.embeddings.float_[0]
image_embedding = response.embeddings.float_[1]from dataclasses import dataclass
from typing import Literal
import numpy as np
@dataclass
class MultimodalDocument:
id: str
modality: Literal["text", "image", "audio", "video"]
content_ref: str # 원본 데이터 참조 (파일 경로 또는 텍스트)
embedding: np.ndarray
metadata: dict
class MultimodalVectorStore:
def __init__(self, dimension: int = 768):
self.documents: list[MultimodalDocument] = []
self.dimension = dimension
def add(self, doc: MultimodalDocument):
assert doc.embedding.shape[0] == self.dimension
self.documents.append(doc)
def search(
self,
query_embedding: np.ndarray,
top_k: int = 10,
modality_filter: str | None = None,
) -> list[tuple[MultimodalDocument, float]]:
"""크로스모달 검색"""
results = []
for doc in self.documents:
if modality_filter and doc.modality != modality_filter:
continue
similarity = np.dot(query_embedding, doc.embedding) / (
np.linalg.norm(query_embedding) * np.linalg.norm(doc.embedding)
)
results.append((doc, float(similarity)))
results.sort(key=lambda x: x[1], reverse=True)
return results[:top_k]
def text_to_image_search(
self, text_embedding: np.ndarray, top_k: int = 10
) -> list[tuple[MultimodalDocument, float]]:
"""텍스트로 이미지 검색"""
return self.search(text_embedding, top_k, modality_filter="image")
def image_to_text_search(
self, image_embedding: np.ndarray, top_k: int = 10
) -> list[tuple[MultimodalDocument, float]]:
"""이미지로 텍스트 검색"""
return self.search(image_embedding, top_k, modality_filter="text")from pinecone import Pinecone
pc = Pinecone(api_key="...")
# 멀티모달 인덱스 생성
index = pc.Index("multimodal-search")
# 이미지 인덱싱
index.upsert(
vectors=[
{
"id": f"img_{i}",
"values": image_embedding.tolist(),
"metadata": {
"modality": "image",
"file_path": f"/images/{filename}",
"description": auto_caption,
"tags": detected_tags,
},
}
for i, (filename, image_embedding, auto_caption, detected_tags)
in enumerate(processed_images)
]
)
# 텍스트로 이미지 검색
text_query = "팀 회의 중인 사람들"
query_embedding = encode_text(text_query)
results = index.query(
vector=query_embedding.tolist(),
top_k=10,
filter={"modality": {"$eq": "image"}},
include_metadata=True,
)검색 품질을 높이기 위해 이미지에 자동으로 캡션을 생성하여 텍스트 임베딩과 함께 저장합니다.
async def index_image_with_caption(
image_path: str,
vlm_client,
clip_model,
vector_store,
):
"""이미지를 캡셔닝하고 멀티모달 인덱싱"""
# 1. VLM으로 상세 캡션 생성
caption = await generate_caption(vlm_client, image_path)
# 2. CLIP 이미지 임베딩
image_embedding = clip_model.encode_image(image_path)
# 3. CLIP 캡션 텍스트 임베딩
caption_embedding = clip_model.encode_text(caption)
# 4. 두 임베딩을 결합하여 저장
# (이미지 임베딩으로 시각적 유사도, 캡션 임베딩으로 의미적 유사도)
vector_store.add(MultimodalDocument(
id=f"img_{hash(image_path)}",
modality="image",
content_ref=image_path,
embedding=image_embedding,
metadata={
"caption": caption,
"caption_embedding": caption_embedding.tolist(),
},
))시각적 유사도와 의미적 유사도를 결합한 하이브리드 검색입니다.
def hybrid_search(
query: str,
clip_model,
vector_store,
visual_weight: float = 0.5,
semantic_weight: float = 0.5,
top_k: int = 10,
) -> list[dict]:
"""시각적 + 의미적 하이브리드 검색"""
query_embedding = clip_model.encode_text(query)
# 1. 시각적 유사도 (CLIP 이미지 임베딩 vs 쿼리)
visual_results = vector_store.search(query_embedding, top_k=top_k * 2)
# 2. 의미적 유사도 (캡션 임베딩 vs 쿼리)
candidates = {}
for doc, visual_score in visual_results:
caption_emb = np.array(doc.metadata.get("caption_embedding", []))
if caption_emb.size > 0:
semantic_score = float(np.dot(query_embedding, caption_emb) / (
np.linalg.norm(query_embedding) * np.linalg.norm(caption_emb)
))
else:
semantic_score = 0.0
combined_score = (
visual_weight * visual_score +
semantic_weight * semantic_score
)
candidates[doc.id] = {
"document": doc,
"visual_score": visual_score,
"semantic_score": semantic_score,
"combined_score": combined_score,
}
# 3. 결합 점수로 정렬
sorted_results = sorted(
candidates.values(),
key=lambda x: x["combined_score"],
reverse=True,
)
return sorted_results[:top_k]하이브리드 검색에서 visual_weight와 semantic_weight의 최적 비율은 데이터와 사용 사례에 따라 다릅니다. 일반적으로 제품 이미지 검색은 시각적 유사도(0.7:0.3), 개념적 검색은 의미적 유사도(0.3:0.7)를 높이는 것이 효과적입니다.
import hashlib
import json
class EmbeddingCache:
def __init__(self, cache_dir: str = ".embedding_cache"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
def _key(self, content: str, model: str) -> str:
return hashlib.sha256(f"{model}:{content}".encode()).hexdigest()
def get(self, content: str, model: str) -> np.ndarray | None:
path = self.cache_dir / f"{self._key(content, model)}.npy"
if path.exists():
return np.load(path)
return None
def set(self, content: str, model: str, embedding: np.ndarray):
path = self.cache_dir / f"{self._key(content, model)}.npy"
np.save(path, embedding)높은 차원의 임베딩은 저장 비용과 검색 속도에 영향을 줍니다.
# 일부 최신 모델은 Matryoshka Representation Learning을 지원
# 임베딩의 앞부분만 잘라도 유의미한 유사도 측정 가능
full_embedding = model.encode(text) # 1024 차원
reduced_embedding = full_embedding[:256] # 256 차원으로 축소
# 정규화
reduced_embedding = reduced_embedding / np.linalg.norm(reduced_embedding)멀티모달 임베딩은 서로 다른 모달리티의 데이터를 통합 벡터 공간에서 비교하고 검색할 수 있게 합니다. CLIP 계열 모델로 이미지와 텍스트를 같은 공간에 매핑하고, 자동 캡셔닝과 하이브리드 검색을 결합하면 높은 정확도의 크로스모달 검색 시스템을 구축할 수 있습니다.
다음 장에서는 이 임베딩 기반 위에 멀티모달 RAG 시스템을 설계합니다. 텍스트, 이미지, 표 등 다양한 모달리티의 문서를 통합적으로 검색하고 LLM에 전달하는 파이프라인을 구축합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
텍스트, 이미지, 표, 차트 등 다양한 모달리티를 통합하는 멀티모달 RAG 시스템의 설계와 구현을 다룹니다. ColPali, 비전 기반 검색, 문서 파싱 전략을 배웁니다.
멀티모달 AI를 활용한 비디오 이해 기법 — 프레임 추출 전략, 시간적 추론, 영상 요약, 그리고 실시간 비디오 분석 파이프라인 설계를 다룹니다.
시각적 이해 능력을 갖춘 AI 에이전트의 설계와 구현 — 화면 상호작용 에이전트, 멀티모달 도구 호출, Computer Use, 그리고 실전 에이전트 패턴을 다룹니다.