이미지 검색과 메트릭 러닝(Image Retrieval & Metric Learning)
검색 시스템은 임베딩(embedding) 공간의 거리로 후보를 순위화합니다. 메트릭 러닝(metric learning)은 그 거리가 우리가 원하는 의미를 갖도록 공간을 다듬는 분야입니다.
유형: Build
언어: Python
선수 조건: Phase 4 Lesson 14(ViT), Phase 4 Lesson 18(CLIP)
소요 시간: 약 45분
학습 목표
- 트리플렛(triplet), 대조(contrastive), 프록시 기반(proxy-based) 메트릭 러닝 손실을 설명하고 데이터셋에 맞는 손실 함수를 고를 수 있습니다.
- L2 정규화(L2-normalisation)와 코사인 유사도(cosine similarity)를 올바르게 구현하고, "같은 항목" 검색과 "같은 클래스" 검색의 차이를 점검할 수 있습니다.
- FAISS 인덱스를 만들고, 텍스트와 이미지로 질의하며, 보류(held-out) 질의 세트의 recall@K를 보고할 수 있습니다.
- DINOv2, CLIP, SigLIP을 즉시 사용할 수 있는 임베딩 백본(embedding backbone)으로 쓰고, 각 모델이 언제 유리한지 압니다.
문제
검색(retrieval)은 운영 비전 시스템 곳곳에 있습니다. 중복 검출, 역이미지 검색, 시각 검색("비슷한 상품 찾기"), 얼굴 재식별(face re-identification), 감시 영상의 사람 재식별(person re-ID), 이커머스의 인스턴스 수준 매칭이 모두 검색 문제입니다. 제품 질문은 늘 같습니다. "이 질의 이미지가 주어졌을 때, 내 카탈로그를 어떤 순서로 보여줄 것인가?"
전체 시스템을 좌우하는 설계 결정은 두 가지입니다. 첫째는 벡터를 만드는 임베딩(embedding) 모델입니다. 둘째는 대규모에서 최근접 이웃을 찾는 인덱스(index) 입니다. 2026년에는 둘 다 꽤 표준화되어 있습니다. 임베딩에는 DINOv2, 인덱스에는 FAISS가 흔히 쓰입니다. 그래서 더 어려운 부분은 애플리케이션에서 무엇을 비슷하다고 볼지 정의하고, 그 정의와 거리가 맞도록 임베딩 공간을 만드는 일입니다.
그 공간을 다듬는 작업이 메트릭 러닝(metric learning)입니다. 작지만 지렛대 효과가 큰 분야입니다.
개념
검색 한눈에 보기
flowchart LR
Q["질의 이미지<br/>또는 텍스트"] --> ENC["인코더"]
ENC --> EMB["질의 임베딩"]
EMB --> IDX["FAISS 인덱스"]
CAT["카탈로그 이미지"] --> ENC2["인코더(동일)"] --> IDX_BUILD["인덱스 구축"]
IDX_BUILD --> IDX
IDX --> RANK["코사인 / L2 기준<br/>Top-k 최근접"]
RANK --> OUT["순위화된 결과"]
style ENC fill:#dbeafe,stroke:#2563eb
style IDX fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
네 가지 손실 계열
| 손실 | 필요한 데이터 | 장점 | 단점 |
|---|
| 대조(Contrastive) | (앵커(anchor), 양성(positive)) + 음성(negative) | 단순하며 어떤 쌍(pair) 레이블에도 쓸 수 있다 | 음성이 많지 않으면 수렴이 느리다 |
| 트리플렛(Triplet) | (앵커, 양성, 음성) | 직관적이며 마진(margin)을 직접 제어한다 | 어려운 트리플렛 마이닝(hard-triplet mining) 비용이 크다 |
| NT-Xent / InfoNCE | 쌍 + 배치(batch)에서 뽑은 음성 | 큰 배치로 확장하기 좋다 | 큰 배치 또는 모멘텀 큐(momentum queue)가 필요하다 |
| 프록시 기반(Proxy-based; ProxyNCA) | 클래스 레이블(class label)만 필요 | 빠르고 안정적이며 마이닝이 필요 없다 | 작은 데이터셋에서는 프록시(proxy)에 과적합할 수 있다 |
대부분의 운영 사례에서는 사전학습된 백본(backbone)에서 시작하고, 즉시 사용할 수 있는 임베딩이 테스트 세트에서 부족할 때만 메트릭 러닝 파인튜닝(fine-tune)을 추가합니다.
트리플렛 손실(Triplet loss)의 수식
L = max(0, ||f(a) - f(p)||^2 - ||f(a) - f(n)||^2 + margin)
앵커 a를 양성 p에 가깝게 당기고, 음성 n에서는 멀어지게 밀어냅니다. margin은 두 거리 사이에 간격을 강제합니다. 세 이미지 구조는 어떤 유사도 순서에도 일반화됩니다.
마이닝(mining)이 중요합니다. 쉬운 트리플렛, 즉 n이 이미 a에서 멀리 떨어져 있는 경우에는 손실이 0입니다. 실제로 네트워크를 가르치는 것은 어려운 트리플렛(hard triplet)입니다. 세미 하드 마이닝(semi-hard mining)은 n이 p보다 멀지만 마진 안에 있는 경우를 고릅니다. 2016년 FaceNet 방식이며 여전히 많이 쓰입니다.
코사인 유사도와 L2
두 가지 거리 척도(metric)와 두 가지 관례가 있습니다.
- 코사인(Cosine): 벡터 사이의 각도입니다. L2 정규화된 임베딩이 필요합니다.
- L2: 유클리드 거리(Euclidean distance)입니다. 정규화되지 않은 임베딩과 정규화된 임베딩 모두에 쓸 수 있지만, 보통 L2 정규화와 제곱 L2(squared L2)를 함께 씁니다.
대부분의 최신 네트워크에서는 두 방식이 사실상 같습니다. ||a - b||^2 = 2 - 2 cos(a, b)가 ||a|| = ||b|| = 1일 때 성립하기 때문입니다. 임베딩을 학습한 방식과 맞는 관례를 고르세요. 둘을 섞으면 "가장 가까움"의 의미가 조용히 바뀝니다.
Recall@K
표준 검색 지표는 다음과 같습니다.
recall@K = 상위 K개 결과 안에 정답 일치(match)가 하나 이상 들어 있는 질의의 비율
recall@1, @5, @10을 나란히 보고합니다. recall@10이 0.95를 넘는데 recall@1이 0.5 미만이면 임베딩 공간의 구조는 맞지만 순위화가 흔들린다는 뜻입니다. 더 오래 파인튜닝하거나 재순위화(re-ranking) 단계를 시도합니다.
중복 검출에서는 거짓 양성(false positive)이 바로 사용자에게 보이는 실수이므로 precision@K가 더 중요합니다. 시각 검색에서는 recall@K가 제품 신호입니다.
FAISS 한 문단 설명
FAISS는 Facebook AI Similarity Search의 약자입니다. 최근접 이웃(nearest-neighbour) 검색의 사실상 표준 라이브러리입니다. 대표적인 인덱스 선택지는 세 가지입니다.
IndexFlatIP / IndexFlatL2: 무차별 대입(brute force) 방식으로 정확(exact)하며 별도 학습이 없습니다. 약 100만 벡터까지 사용합니다.
IndexIVFFlat: K개 셀로 나누고 가까운 몇 셀만 검색합니다. 근사(approximate) 방식이며 빠르고 학습 데이터가 필요합니다.
IndexHNSW: 그래프 기반입니다. 질의가 많을 때 가장 빠르지만 인덱스 크기가 큽니다.
10만 벡터에는 코사인 유사도 기반 IndexFlatIP가 보통 적합합니다. 1천만 벡터에는 IndexIVFFlat을, 1억 이상에는 곱 양자화(product quantisation)를 결합한 IndexIVFPQ를 고려합니다.
인스턴스 수준과 카테고리 수준 검색
같은 "검색"이라는 이름을 쓰지만 완전히 다른 문제입니다.
- 카테고리 수준(Category-level): "카탈로그에서 고양이를 찾아줘"입니다. 클래스 조건 유사도이며 즉시 사용 가능한 CLIP / DINOv2 임베딩이 잘 동작합니다.
- 인스턴스 수준(Instance-level): "이 정확한 상품을 카탈로그에서 찾아줘"입니다. 같은 클래스 안에서 시각적으로 비슷한 객체를 세밀하게 구분해야 합니다. 즉시 사용 가능한 임베딩은 부족한 경우가 많고 메트릭 러닝 파인튜닝이 중요합니다.
모델을 고르기 전에 항상 어느 문제를 푸는지 먼저 물어야 합니다.
만들어 보기
Step 1: 트리플렛 손실(Triplet loss)
import torch
import torch.nn.functional as F
def triplet_loss(anchor, positive, negative, margin=0.2):
d_ap = F.pairwise_distance(anchor, positive, p=2)
d_an = F.pairwise_distance(anchor, negative, p=2)
return F.relu(d_ap - d_an + margin).mean()
한 줄입니다. L2 정규화된 임베딩과 정규화되지 않은 임베딩 모두에서 동작합니다.
Step 2: 세미 하드 마이닝(Semi-hard mining)
임베딩 배치와 레이블이 주어졌을 때, 각 앵커에 대해 가장 어려운 세미 하드 음성을 찾습니다.
def semi_hard_negatives(emb, labels, margin=0.2):
dist = torch.cdist(emb, emb)
same_class = labels[:, None] == labels[None, :]
diff_class = ~same_class
N = emb.size(0)
positives = dist.clone()
positives[~same_class] = float("-inf")
positives.fill_diagonal_(float("-inf"))
pos_idx = positives.argmax(dim=1)
semi_hard = dist.clone()
semi_hard[same_class] = float("inf")
d_ap = dist[torch.arange(N), pos_idx].unsqueeze(1)
semi_hard[dist <= d_ap] = float("inf")
neg_idx = semi_hard.argmin(dim=1)
fallback_mask = semi_hard[torch.arange(N), neg_idx] == float("inf")
if fallback_mask.any():
hardest = dist.clone()
hardest[same_class] = float("inf")
neg_idx = torch.where(fallback_mask, hardest.argmin(dim=1), neg_idx)
return pos_idx, neg_idx
각 앵커는 같은 클래스의 가장 어려운 양성(hardest positive)과, 양성보다 멀지만 마진 안에 있는 세미 하드 음성을 받습니다.
Step 3: Recall@K
def recall_at_k(query_emb, gallery_emb, query_labels, gallery_labels, k=1):
sim = query_emb @ gallery_emb.T
_, top_k = sim.topk(k, dim=-1)
matches = (gallery_labels[top_k] == query_labels[:, None]).any(dim=-1)
return matches.float().mean().item()
L2 정규화된 임베딩에서 내적(inner product) 기준 상위 k개는 코사인 기준 상위 k개와 같습니다. 정답 이웃이 하나 이상 포함된 질의의 평균 비율을 보고합니다.
Step 4: 한데 묶기
import torch
import torch.nn as nn
from torch.optim import Adam
class Encoder(nn.Module):
def __init__(self, in_dim=128, emb_dim=64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(in_dim, 128), nn.ReLU(),
nn.Linear(128, emb_dim),
)
def forward(self, x):
return F.normalize(self.net(x), dim=-1)
torch.manual_seed(0)
num_classes = 6
protos = F.normalize(torch.randn(num_classes, 128), dim=-1)
def sample_batch(bs=32):
labels = torch.randint(0, num_classes, (bs,))
x = protos[labels] + 0.15 * torch.randn(bs, 128)
return x, labels
enc = Encoder()
opt = Adam(enc.parameters(), lr=3e-3)
for step in range(200):
x, y = sample_batch(32)
emb = enc(x)
pos_idx, neg_idx = semi_hard_negatives(emb, y)
loss = triplet_loss(emb, emb[pos_idx], emb[neg_idx])
opt.zero_grad(); loss.backward(); opt.step()
몇백 스텝(step) 뒤에는 임베딩 클러스터가 클래스별로 하나씩 형성됩니다.
사용하기
2026년 운영 스택은 다음과 같습니다.
- DINOv2 + FAISS: 범용 시각 검색입니다. 즉시 사용해도 잘 동작합니다.
- CLIP + FAISS: 질의가 텍스트일 때 사용합니다.
- 파인튜닝된 DINOv2(Fine-tuned DINOv2) + FAISS: 인스턴스 수준 검색, 얼굴 재식별(face re-ID), 패션, 이커머스에 사용합니다.
- Milvus / Weaviate / Qdrant: FAISS 또는 HNSW를 감싼 관리형 벡터 데이터베이스(managed vector DB)입니다.
최신 기술(SOTA) 수준의 인스턴스 검색 레시피는 DINOv2 백본에 임베딩 헤드(embedding head)를 붙이고, 인스턴스 레이블 쌍(instance label pair)에 트리플렛 또는 InfoNCE 손실로 파인튜닝한 뒤, FAISS에 색인하는 것입니다.
산출물 만들기
이 레슨에서는 다음을 만듭니다.
outputs/prompt-retrieval-loss-picker.md: 검색 문제에 대해 triplet / InfoNCE / ProxyNCA 중 적절한 손실을 고르는 프롬프트입니다.
outputs/skill-recall-at-k-runner.md: 학습/검증/갤러리(train/val/gallery) 분할과 명확한 데이터 계약(data contract)을 갖춘 recall@K 평가 하니스(harness)를 작성하는 스킬입니다.
연습문제
- (쉬움) 위 장난감 예제(toy example)를 실행합니다. 학습 전후 임베딩을 PCA로 그려 여섯 개 클러스터가 형성되는지 확인합니다.
- (중간) ProxyNCA 손실을 구현합니다. 클래스마다 하나의 학습 가능한 프록시를 두고 코사인 유사도에 표준 교차 엔트로피(standard cross-entropy)를 적용합니다. 장난감 데이터에서 트리플렛 손실과 수렴 속도를 비교합니다.
- (어려움) ImageNet 검증(validation) 이미지 1,000장을 HuggingFace의 DINOv2로 임베딩하고, FAISS 플랫 인덱스(flat index)를 만든 뒤, 같은 이미지를 질의로 넣었을 때의 recall@{1, 5, 10}을 보고합니다. 또한 ImageNet 레이블을 정답(ground truth)으로 하는 보류 분할(held-out split)에서도 보고합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 메트릭 러닝(Metric learning) | "공간을 다듬기" | 출력 공간의 거리가 목표 유사도를 반영하도록 인코더(encoder)를 학습하는 것 |
| 트리플렛 손실(Triplet loss) | "당기고 밀기" | L = max(0, d(a, p) - d(a, n) + margin) 형태의 대표 메트릭 러닝 손실 |
| 세미 하드 마이닝(Semi-hard mining) | "유용한 음성" | 앵커에서 양성보다 멀지만 마진 안에 있는 음성. 경험적으로 가장 정보량이 많다 |
| 프록시 기반 손실(Proxy-based loss) | "클래스 원형(prototype)" | 클래스마다 하나의 학습 프록시를 두고 프록시 유사도에 교차 엔트로피를 적용한다. 쌍 마이닝(pair mining)이 필요 없다 |
| Recall@K | "상위 K 적중률(Top-K hit rate)" | 상위 K 안에 정답 결과가 하나 이상 있는 질의의 비율 |
| 인스턴스 검색(Instance retrieval) | "정확히 이 물건 찾기" | 세밀한 매칭 문제. 즉시 사용 가능한 특징(feature)은 보통 부족하다 |
| FAISS | "최근접 이웃 라이브러리" | Facebook의 최근접 이웃 라이브러리. 정확(exact) 인덱스와 근사(approximate) 인덱스를 지원한다 |
| HNSW | "그래프 인덱스" | 계층 탐색 가능한 작은 세계(Hierarchical navigable small world). 메모리 부담이 작고 빠른 근사 최근접 이웃 방식 |
더 읽을거리