코사인 유사도 임계값 0.8: 임베딩 이방성(Embedding Anisotropy)이 내 RAG를 망가뜨렸다
요약
RAG 시스템에서 사용하던 코사인 유사도 임계값(0.8)이 임베딩 모델 교체 후 작동하지 않는 문제가 발생했습니다. 이는 코사인 유사도 점수가 모델에 따라 보편적인 의미를 갖지 않기 때문입니다. 핵심 원인은 '임베딩 이방성'이며, 해결책으로 상대적 신호나 리랭커 사용을 제안합니다.
핵심 포인트
- 코사인 유사도는 임베딩 모델마다 의미가 다르다.
- 임베딩 이방성이 관련 없는 텍스트도 높은 점수를 받게 한다.
- 절대적인 코사인 값 대신 상대적 신호를 사용하는 것이 좋다.
- 모델 교체 시 반드시 새로운 임계값 재보정이 필요하다.
내 RAG 봇에는 단 하나의 안전 규칙이 있었다. 만약 가장 좋은 청크의 점수가 0.8 코사인 유사도 미만이면, '모르겠습니다'라고 답변하는 것이었다. 이 코사인 유사도 임계값은 몇 달 동안 아무 문제 없이 작동했다. 그러다가 내가 임베딩 모델을 교체하고 재배포했더니, 봇이 모든 것에 대해 '모르겠습니다'라고 말했다. 심지어 전용 문서 페이지가 있는 '비밀번호를 어떻게 초기화하나요?' 같은 질문에도 그랬다.
아무것도 충돌하지 않았다. 에러도 없었다. 검색은 여전히 순위 1에서 올바른 청크를 반환했다. 하지만 내 게이트(gate)는 그것을 매번 걸러버렸다.
가장 최악인 부분은, 내가 파고들어 보니 예전 설정 역시 망가져 있었다는 것을 발견한 것이다. 다른 방향으로 말이다.
요약 (TL;DR)
- 코사인 유사도 점수는 보편적인 의미를 갖지 않는다. 0.8이라는 값은 한 임베딩 모델에게는 '거의 중복'일 수 있지만, 다른 모델에게는 '무작위 노이즈'일 수 있다.
- **임베딩 이방성(Embedding anisotropy)**이 원인이다: 대부분의 모델은 모든 벡터를 좁은 원뿔 안에 압축하기 때문에, 관련 없는 텍스트조차 높은 점수를 받는다. 얼마나 좁게 모이는지는 모델에 따라 다르다.
- 대조 학습(Contrastive training)은 순위만 가르칠 뿐, 절대적인 점수는 가르치지 않는다. 모든 유사도에 상수를 더해도 손실 함수는 변하지 않기 때문에, 아무것도 0.8을 '관련 있음'으로 고정시키지 못한다.
- 해결책: 사용자의 코퍼스에서 관련 없는 쌍의 기준선(baseline) 유사도를 측정하고, 작은 레이블링된 세트에서 임계값을 보정하며, 모델이 바뀔 때마다 재보정해야 한다.
- 더 나은 방법은 원시적인 코사인 수치 대신 상대적 신호(점수 대 기준선 백분위수, top-1 마진)나 리랭커(reranker)에 기반하여 게이팅하는 것이다.
임베딩 모델을 바꿨을 때 무슨 일이 일어났는가?
순위 순서는 마이그레이션 과정에서 살아남았지만, 점수 스케일은 붕괴했다. 모든 관련성 높은 매치가 내 0.8 임계값보다 훨씬 낮아졌고, 그래서 게이트가 그들을 모두 거부한 것이다.
내 로그에 기록된 내용을 대략적으로 보여주자, 동일한 내부 문서 코퍼스에서:
| 이전 모델 (ada-002) | 새 모델 (text-embedding-3-small) | |
|---|---|---|
| 실제 답변이 있는 질문의 Top-1 점수 | ~0.84 중앙값 | ~0.55 중앙값 |
| 무작위 관련 없는 청크 두 개 사이의 점수 | ~0.72 중앙값 | ~0.18 중앙값 |
오른쪽 열을 먼저 보세요. 새 모델이 관련 내용과 무관한 내용을 분리하는 데 더 뛰어났습니다. '실제 답변(real answer)'과 '노이즈(noise)' 사이의 간격이 넓어졌습니다. 제 임계값은 전체 분포 위에 놓여 있었습니다.
이제 왼쪽 열을 보세요. 이전 모델에서는 무작위 잡음(random junk)이 0.72점을 받고 실제 답변이 0.84점을 받았습니다. 저의 '안전' 임계값인 0.8은 쓰레기와 금 사이의 0.12 폭의 구간에 놓여 있었습니다. 저는 나폴리의 피자에 관한 주제에서 벗어난 테스트 질문을 했고, 그것이 Kubernetes 런북(runbook)과 비교하여 0.77점을 받았습니다. 저의 가드레일은 0.003점 차이로 유지되고 있었습니다.
따라서 임계값은 결코 가드레일이 아니었습니다. 단지 한 모델에 우연히 작동했던 것뿐입니다.
관련 없는 텍스트 간 코사인 유사도가 높은 이유는 무엇인가요?
대부분의 임베딩 모델이 이방성(anisotropic)이기 때문입니다. 즉, 벡터들이 전체 구(sphere)에 퍼지는 것이 아니라 좁은 원뿔(cone) 안에 밀집되어 있습니다. 좁은 원뿔 안에 있는 어떤 두 벡터든 그 사이 각도가 작기 때문에, 텍스트 내용과 상관없이 코사인 유사도는 높아지게 됩니다.
이것은 특정 공급업체의 특이점이 아닙니다. 수년 전 BERT와 GPT-2 표현(representation)에 대한 연구에서도 나타났습니다 (Ethayarajh, 2019는 문맥적 임베딩이 좁은 원뿔을 차지한다고 발견). 그리고 '표현 퇴행(representation degeneration)'은 그 이후로 언어 모델에서 연구되어 왔습니다.
수학으로 10초 만에 이해할 수 있습니다. 모든 임베딩을 공유 구성 요소와 고유한 구성 요소의 합으로 모델링합니다:
v = m + r
|m|^2 = a^2 (모든 텍스트가 공유하는 방향)
|r|^2 = b^2 (이 텍스트를 독특하게 만드는 것)
만약 고유한 부분이 서로 그리고 m에 대해 대략 직교한다면, 두 관련 없는 텍스트의 코사인은 다음과 같습니다:
cos(v1, v2) ≈ a^2 / (a^2 + b^2)
만약 공유 방향이 각 벡터 제곱 길이의 70%를 차지한다면, 공통점이 전혀 없는 두 텍스트는 약 0.7점을 받습니다. 실제 의미 전체가 남아있는 범위의 0.3에 들어가야 합니다. 이것이 바로 제 ada-002 수치들이 보였던 모습입니다.
서로 다른 모델들은 서로 다른 a^2 / (a^2 + b^2) 값을 갖게 됩니다. 이 비율이 바로 노이즈 플로어(noise floor)이며, 제품 상자에는 인쇄되어 있지 않습니다.
임베딩 모델 간 점수 스케일이 달라지는 이유
임베딩 모델들은 특정 점수를 요구하는 학습 목표를 갖지 않기 때문에 점수 스케일이 다르게 나타납니다. 대부분의 임베딩 모델은 대비 손실(contrastive loss, InfoNCE 또는 이와 유사한 방식)을 사용하여 훈련되는데, 이는 단지 올바른 쌍이 틀린 쌍보다 우세한지 여부만을 신경 쓰기 때문입니다.
하나의 쿼리에 대한 손실 함수는 다음과 같습니다:
loss = -log( exp(s_pos / τ) / Σ_i exp(s_i / τ) )
모든 유사도 $s_i$에 상수 $c$를 더해도 softmax 값은 변하지 않습니다. $\exp(c / \tau)$ 인자는 분자와 분모에서 상쇄됩니다. 따라서 모델은 "무관한" 쌍의 점수를 0.1로 설정하든 0.7로 설정하든 자유롭습니다. 학습 과정에서는 아무것도 신경 쓰지 않습니다.
온도($\tau$)와 훈련 데이터가 최종적으로 점수가 얼마나 퍼질지를 결정합니다. 모델마다, 레시피마다, 스케일마다 다릅니다. 당신의 0.8이라는 값은 이 과정을 거치면서 살아남기 어렵습니다.
또 하나의 함정: 만약 임베딩 벡터를 직접 짧게 자른다면(마트로시카 스타일의 벡터를 더 낮은 차원으로 슬라이싱하는 경우), 그 벡터는 더 이상 단위 길이(unit length)가 아닙니다. 따라서 슬라이싱 후 반드시 재정규화(Renormalize)해야 합니다. 그렇지 않으면 당신이 말하는 "코사인 유사도"는 실제로는 노름(norm)이 변동된 내적(dot product)일 뿐입니다.
코사인 유사도 임계값 사용이 안전한가?
네, 만약 그 임계값이 당신의 코퍼스(corpus)에서, 당신의 정확한 모델을 사용하여 측정되었고, 둘 중 어느 하나라도 변경될 때마다 재측정한다면 안전합니다. 블로그 게시물이나 튜토리얼 기본값, 또는 이전 모델에서 복사해 온 임계값은 그저 추측일 뿐입니다.
벡터 검색에서의 순위(ranking)는 보통 신뢰할 만합니다. 하지만 옆에 붙어 있는 절대적인 수치는 보정(calibrate)하기 전까지는 그렇지 않습니다.
코사인 유사도 임계값을 어떻게 보정하는가?
두 단계로 진행됩니다: 노이즈 플로어(noise floor)를 측정하고, 그 다음 레이블링된 예제들을 기반으로 임계값을 적합(fit)시킵니다.
1단계: 노이즈 플로어. 자신만의 인덱스에서 무작위 청크 쌍을 샘플링하여 분포를 확인합니다. 이 작업은 몇 분밖에 걸리지 않으며, 이 모델에게 "무관한" 점수가 어디에 위치하는지 알려줍니다:
import numpy as np
def noise_floor(E, n=5000, seed=0):
...
같은 코퍼스에서 나온 무작위 쌍들은 "무관함"을 약간 과대평가할 수 있습니다 (청크들이 주제와 전문 용어를 공유하기 때문에). 이는 괜찮습니다. 당신은 보수적인(conservative) 플로어 값을 원합니다.
단계 2: 레이블링된 세트 구축. 문서가 답변하는 질문 4050개와 그렇지 않은 질문 4050개를 작성합니다. 각 질문에 대해 top-1 점수를 기록하세요. 그런 다음 필요한 정밀도(precision)를 달성하는 임계값(threshold)을 선택합니다:
def pick_threshold(pos_scores, neg_scores, min_precision=0.95):
candidates = sorted(set(pos_scores) | set(neg_scores))
for t in candidates:
...
만약 어떤 임계값으로도 적절한 재현율(recall)을 확보할 수 없다면, 문제는 임계값이 아닙니다. 사용자의 임베딩이 해당 질문들을 분리하지 못하는 것이므로, 리랭커(reranker)나 더 좋은 청크를 사용해야 합니다.
모델 이름과 버전을 이 임계값 옆에 설정 파일(config)에 저장하세요. 누군가 모델을 변경할 때, 오래된 임계값이 조용히 실패하는 것이 아니라 명확하게 오류를 발생시켜야 합니다.
평균 중심화(mean-centering)로 임베딩의 이방성(anisotropy)이 해결되나요?
강한 공유 방향을 가진 모델에는 큰 도움이 됩니다. 코퍼스 평균 벡터(corpus mean vector)를 빼고, 재정규화하면 원뿔(cone)이 열립니다. 이것이
- 점수(Score) 대 노이즈 플로어(Noise floor). 무작위 쌍 유사도(random-pair similarity)의 99번째 백분위수를 넘어서는 경우에만 상위 결과를 채택합니다. 모델이 변경될 때마다 이 기준값(floor)을 재계산하고 게이트를 그 값에 맞춰 이동시켜야 합니다.
- Top-1 마진(margin). 질의(query)가 실제 내용과 일치하는 경우, 상위 결과는 보통 다음 몇 개의 결과와 확연히 구분됩니다. 그러나 아무것도 일치하지 않는 경우에는 점수들이 평평하게 나옵니다.
top1 - median(top2..top10)은 비용이 적게 들고 스케일 상대적인 신호입니다. - 최종 관련성 판별을 위한 크로스 인코더 재랭커(cross-encoder reranker). 이 모델은 질의와 청크를 함께 읽기 때문에, 두 개의 독립적인 벡터를 비교하는 대신 직접적으로 관련성을 판단합니다. 이 역시 점수 보정(calibration)이 필요합니다 (많은 경우 원시 로짓(raw logits)을 출력함). 하지만 관련 있는 것과 관련 없는 것을 훨씬 더 깨끗하게 분리해 줍니다.
매번 임베딩 변경 시 실행하는 체크리스트
- 샘플을 재임베딩하고, 무작위 쌍 분포를 플롯합니다. p50과 p99 값을 기록합니다.
- 레이블링된 세트(labeled set)에 대해 점수를 재계산합니다. 임계값(threshold)을 다시 설정합니다. 모델 이름과 함께 커밋합니다.
- 벡터 노름(vector norms)을 확인합니다. 만약 값이 ~1.0이 아니라면, 정규화하거나 의도적으로 내적(dot product) 방식으로 전환합니다.
- 레이블링된 세트에서 이전 상위-1 결과와 새로운 결과를 비교합니다. 순위 변화가 실제 마이그레이션 위험입니다.
- "모르겠다(I don't know)" 비율이 급증할 때 경고를 설정합니다. 이 지표는 제가 하루가 아닌 한 시간 만에 장애 상황을 파악하는 데 도움이 되었을 것입니다.
그렇다면 코사인 유사도 0.8은 어떤 의미인가요?
그 자체로는 아무 의미가 없습니다. 0.8과 같은 코사인 유사도 임계값은 오직 특정 임베딩 모델과 특정 코퍼스에 한해서만 의미를 가집니다. 왜냐하면 임베딩 이방성(Embedding Anisotropy)과 대비 학습(contrastive training)은 각 모델마다 고유의 점수 스케일과 노이즈 플로어를 남기기 때문입니다. 관련 없는 텍스트라도 한 모델에서는 0.7을 기록할 수 있고 다른 모델에서는 0.15를 기록할 수 있습니다. 벡터 검색이 제공하는 순위를 신뢰하세요. 이 숫자를 임의 쌍 유사도를 자체 인덱스에서 측정하고 레이블링된 예시로부터 임계값을 선택하기 전까지는 보정되지 않은 값으로 취급해야 합니다. 그리고 모델이 변경될 때마다 둘 다 다시 수행해야 합니다.
작성자: 인터뷰 준비 플랫폼인 Preterview의 개발자.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기