왜 차원이 문제가 되는가
임베딩 기반 검색은 벡터 차원이 클수록 표현력이 좋아지지만, 저장 비용과 검색 지연이 선형적으로 늘어난다. 1536차원 float32 벡터 하나는 6KB다. 1천만 개면 원본만 60GB이고, HNSW 그래프 메모리까지 더하면 실 운영에서는 100GB를 훌쩍 넘는다. 지연 시간도 마찬가지다. 거리 계산 비용은 차원에 비례하므로, 차원을 절반으로 줄이면 후보 스코어링 단계가 거의 2배 빨라진다.
문제는 무작정 줄이면 검색 품질(recall)이 무너진다는 점이다. 핵심은 "품질 손실을 허용 범위 안에 두면서 비용을 줄이는 지점"을 찾는 것이다.
차원 축소의 세 갈래
실무에서 쓰는 방법은 크게 세 가지다. 각각 손실 지점과 운영 난이도가 다르다.
| 방법 | 비용 절감 | 품질 손실 | 운영 난이도 |
|---|---|---|---|
| Matryoshka(MRL) 절단 | 차원 비례 | 작음(학습된 경우) | 낮음 |
| PQ / 스칼라 양자화 | 4~32배 | 중간 | 중간 |
| PCA / 랜덤 프로젝션 | 차원 비례 | 모델 의존 | 중간 |
가장 안전한 순서는 Matryoshka 지원 모델이면 절단부터 시도하고, 그다음 양자화, 마지막으로 PCA다. PCA는 학습 데이터 분포에 의존하므로 도메인이 바뀌면 재학습이 필요하다.
Matryoshka 절단: 가장 손쉬운 축소
Matryoshka 학습된 임베딩은 앞쪽 차원에 중요한 정보가 몰려 있어, 단순히 앞부분만 잘라도 의미가 보존된다. 절단 후 재정규화만 해주면 된다.
import numpy as np
def truncate_normalize(vecs: np.ndarray, dim: int) -> np.ndarray:
# vecs: (N, D) 원본 임베딩, dim: 목표 차원
cut = vecs[:, :dim]
norms = np.linalg.norm(cut, axis=1, keepdims=True)
return cut / np.clip(norms, 1e-9, None)
full = load_embeddings() # (N, 1536)
small = truncate_normalize(full, 512) # (N, 512), 저장 1/3
주의할 점은 재정규화를 생략하면 코사인 유사도 순위가 뒤틀린다는 것이다. 절단으로 노름이 달라지기 때문에, 코사인 인덱스를 쓴다면 반드시 재정규화해야 한다.
양자화로 메모리 4배 이상 줄이기
차원은 유지하되 각 성분의 정밀도를 낮추는 접근이다. pgvector 0.7 이상은 스칼라 양자화(halfvec)와 이진 양자화를 지원한다. halfvec은 float16으로 저장해 크기를 절반으로 줄이면서 품질 손실이 거의 없다.
-- float32 -> float16 저장, HNSW 인덱스도 halfvec으로
ALTER TABLE docs
ALTER COLUMN embedding TYPE halfvec(1536)
USING embedding::halfvec(1536);
CREATE INDEX ON docs
USING hnsw (embedding halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 조회 시 재랭킹은 원본 벡터로
SELECT id, embedding <=> :query AS dist
FROM docs
ORDER BY dist
LIMIT 50;
이진 양자화는 32배까지 줄지만 recall 하락이 크다. 그래서 "이진으로 넓게 후보를 뽑고, 원본 float로 상위 후보만 재랭킹"하는 2단계 구성이 정석이다.
손실을 반드시 측정하라
축소를 적용했다면 감이 아니라 숫자로 검증해야 한다. 기준은 원본 전차원 검색을 정답으로 둔 recall@k다.
def recall_at_k(gt_ids, pred_ids, k=10):
hits = 0
for gt, pred in zip(gt_ids, pred_ids):
hits += len(set(gt[:k]) & set(pred[:k]))
return hits / (len(gt_ids) * k)
# 예: 512차원 절단본이 원본 대비 recall@10 = 0.96 이면 허용
r = recall_at_k(full_topk, small_topk, k=10)
print(f"recall@10 = {r:.3f}")
운영 SLA에 맞춰 임계선을 정한다. 검색이 최종 답변의 컨텍스트로만 쓰인다면 recall@10 0.95 정도는 대개 무해하다. 하지만 법률·의료처럼 누락이 치명적인 도메인은 0.99 이상을 요구하기도 한다.
운영에서의 주의점
차원을 바꾸면 인덱스를 통째로 재빌드해야 하므로, 무중단 전환을 위해 신규 컬럼·신규 인덱스를 만들고 배치로 채운 뒤 교체하는 방식을 권한다. 또한 축소 파라미터(목표 차원, 양자화 방식)는 임베딩 모델 버전과 함께 메타데이터로 기록해 두어야 한다. 모델을 교체했는데 예전 축소 설정을 그대로 쓰면 recall이 조용히 무너진다.
마지막으로, 축소는 검색 파이프라인의 한 단계일 뿐이다. ef_search 같은 인덱스 탐색 파라미터와 재랭킹 단계를 함께 튜닝해야 실제 체감 품질과 비용이 균형에 도달한다.