왜 고정 차원 임베딩이 문제가 되는가
대부분의 임베딩 모델은 1024차원, 1536차원처럼 고정된 벡터를 출력한다. 벡터 검색 시스템에서 이 차원 수는 그대로 비용이 된다. 100만 개 문서를 1536차원 float32로 저장하면 약 6GB의 메모리가 필요하고, 유사도 계산량도 차원에 비례해 늘어난다. 리랭킹 없이 전체 코퍼스를 훑는 브루트포스 검색이나 HNSW 인덱스 빌드 시간 모두 차원에 직접 영향을 받는다.
문제는 모든 검색이 최고 정밀도를 필요로 하지 않는다는 점이다. 1차 후보군을 빠르게 좁히는 단계에서는 낮은 차원으로 충분하고, 최종 정렬에서만 고차원이 필요하다. 하지만 고정 차원 모델은 이 유연성을 주지 못한다. 차원을 줄이려면 별도 모델을 다시 학습하거나 PCA 같은 후처리를 붙여야 하는데, 둘 다 운영 부담이 크다.
Matryoshka 표현 학습의 핵심 아이디어
Matryoshka Representation Learning(MRL)은 하나의 벡터 안에 여러 해상도의 표현을 중첩시킨다. 러시아 인형처럼 앞쪽 차원일수록 더 중요한 정보를 담도록 학습되기 때문에, 1536차원 벡터의 앞 256차원만 잘라 써도 의미 있는 검색이 가능하다.
학습 시점에 여러 절단 길이(예: 64, 128, 256, 512, 1024)에 대해 각각 손실을 계산하고 합산한다. 그 결과 어느 지점에서 잘라도 정보가 앞으로 쏠린 벡터가 만들어진다. 추론 시에는 모델을 바꿀 필요 없이 슬라이싱만으로 원하는 차원을 얻는다.
import numpy as np
# MRL 모델이 출력한 1536차원 벡터 (정규화 전)
full = model.encode("반품 정책 알려줘") # shape: (1536,)
def truncate_and_normalize(vec, dim):
sliced = vec[:dim] # 앞쪽 dim개만 절단
norm = np.linalg.norm(sliced)
return sliced / norm # 절단 후 반드시 재정규화
v256 = truncate_and_normalize(full, 256)
v768 = truncate_and_normalize(full, 768)
주의할 점은 절단 후 반드시 L2 재정규화를 해야 한다는 것이다. 벡터의 일부만 남기면 노름이 달라져 코사인 유사도가 왜곡된다.
2단계 검색 파이프라인 설계
실무에서 가장 효과가 큰 패턴은 저차원 후보 검색 + 고차원 리랭킹이다. 저장은 고차원 원본으로 하되, 1차 탐색은 앞 256차원으로 대량 후보를 빠르게 걸러내고, 상위 100~200개만 전체 차원으로 다시 정렬한다.
def two_stage_search(query_full, index, k=10, shortlist=200):
# 1단계: 저차원으로 넓게 후보 확보
q_low = truncate_and_normalize(query_full, 256)
candidates = index.search_low(q_low, top_k=shortlist)
# 2단계: 후보만 전체 차원으로 정밀 재정렬
q_high = truncate_and_normalize(query_full, 1536)
rescored = []
for doc_id in candidates:
d_high = index.get_full_vector(doc_id)
score = float(np.dot(q_high, d_high))
rescored.append((doc_id, score))
rescored.sort(key=lambda x: x[1], reverse=True)
return rescored[:k]
이 구조에서 1단계는 차원이 1/6로 줄어 연산량이 크게 감소하고, 2단계는 후보가 수백 개뿐이라 전체 정확도를 거의 유지하면서도 지연을 낮춘다.
벡터 DB 설정 예시
pgvector에서는 저차원 컬럼과 고차원 컬럼을 나눠 저장하는 방식이 단순하고 안정적이다. 저차원에만 ANN 인덱스를 걸고, 고차원은 리랭킹용 조회로만 쓴다.
CREATE TABLE docs (
id bigserial PRIMARY KEY,
content text,
emb_256 vector(256), -- 1차 후보 검색용
emb_1536 vector(1536) -- 리랭킹용
);
-- 저차원에만 HNSW 인덱스
CREATE INDEX ON docs USING hnsw (emb_256 vector_cosine_ops);
-- 1단계 후보 조회
SELECT id FROM docs
ORDER BY emb_256 <=> :query_256
LIMIT 200;
차원별 트레이드오프 비교
어느 차원을 1단계에 쓸지는 코퍼스 크기와 요구 재현율에 따라 다르다. 일반적으로 아래와 같은 경향이 나타난다. 실제 수치는 반드시 자신의 데이터로 재측정해야 한다.
| 절단 차원 | 저장/메모리 | 검색 속도 | 재현율 경향 | 권장 용도 |
|---|---|---|---|---|
| 128 | 매우 낮음 | 매우 빠름 | 손실 큼 | 초대규모 1차 필터 |
| 256 | 낮음 | 빠름 | 실용적 | 2단계 파이프라인 후보 |
| 768 | 중간 | 보통 | 양호 | 단일 단계 검색 |
| 1536 | 높음 | 느림 | 최고 | 리랭킹 최종 정렬 |
도입 시 주의점
첫째, 임의의 모델을 잘라 쓸 수는 없다. MRL로 학습된 모델이어야 앞쪽 차원에 정보가 집중된다. 일반 모델을 절단하면 성능이 급격히 떨어진다. 모델 카드에서 Matryoshka 지원 여부를 반드시 확인해야 한다.
둘째, 절단 후 재정규화를 빠뜨리면 유사도 순위가 미묘하게 틀어진다. 이 버그는 눈에 잘 안 띄므로 파이프라인에 단위 테스트로 고정해 두는 편이 좋다.
셋째, 차원 선택은 감이 아니라 측정으로 정해야 한다. 후보 shortlist 크기와 절단 차원을 바꿔가며 자신의 쿼리 로그로 recall@k와 지연을 함께 측정하고, 재현율 손실이 허용 범위에 드는 최소 차원을 고른다.
넷째, 저차원과 고차원을 모두 저장하면 총 저장량은 오히려 늘어난다. 저장 비용보다 검색 지연과 연산 비용이 병목일 때 이 방식이 이득이므로, 병목이 어디인지 먼저 확인한 뒤 적용하는 것이 순서다.