지난 글에서 벡터 검색의 전체 흐름을 살펴봤다. 임베딩 모델이 텍스트를 숫자 벡터로 바꾸고, 뜻이 비슷한 글은 그 공간에서 가까이 놓인다는 이야기였다. 그런데 「가깝다」는 말은 저절로 정해지지 않는다. 두 벡터를 받아 수 하나를 돌려주는 규칙을 정해야 하고, 그 규칙이 유사도 지표다. 이 글은 벡터 검색에서 쓰는 세 지표인 코사인 유사도, 유클리드 거리, 내적을 정의와 손 계산부터 시작해 정규화가 만드는 등가, 데이터베이스 연산자의 부호, 실제로 고르는 절차까지 차례로 다룬다.
지표의 역할
순위의 갈림
임베딩은 텍스트 한 조각을 수백~수천 개의 실수로 된 벡터로 바꾼 결과이고, 벡터 검색은 질의의 임베딩과 문서 임베딩들 사이에 지표를 계산해 가까운 순으로 줄 세우는 일이다. 임베딩이 같아도 지표가 다르면 줄이 달라진다. 지표마다 벡터의 어떤 성질을 보는지가 달라서다. 어떤 지표는 방향만 보고, 어떤 지표는 방향과 함께 길이도 본다. 벡터의 길이를 노름이라 부르는데, 각 성분을 제곱해 더한 값의 제곱근으로 원점에서 벡터 끝까지의 직선 길이다. 이 글 전체가 결국 노름을 셈에 넣느냐 빼느냐의 이야기다.
뒤에서 손으로 계산해 보겠지만, 2차원 벡터 넷만으로도 세 지표가 세 가지 다른 순위를 낸다. 수백 차원의 실제 임베딩에서는 차이가 그만큼 극적이지 않을 때가 많다. 그래도 상위 몇 건이 바뀌는 것만으로 결과가 달라지는 시스템이 있다. 검색 결과를 LLM의 문맥으로 넣는 RAG가 그렇다. 상위 다섯 건 중 한 건이 바뀌면 모델이 읽는 근거가 바뀌고, 답이 바뀐다.
인덱스와 지표
두 번째 이유는 인덱스다. 문서가 수백만 건이면 질의마다 모든 문서와 값을 재는 대신 ANN 인덱스를 쓴다. ANN은 근사 최근접 이웃 탐색으로, 정확도를 조금 내주고 살펴볼 후보를 크게 줄여 빨리 찾는 방법이며, 인덱스는 그 방법을 위해 미리 짜 둔 자료 구조다. 이 인덱스는 만들 때 어느 지표로 가까움을 잴지를 정해 두고, 그 지표에 맞춰 이웃 그래프나 군집을 짠다.
pgvector에서는 인덱스를 만들 때 vector_l2_ops·vector_ip_ops·vector_cosine_ops 중 하나를 연산자 클래스로 고르고, Faiss에서는 IndexFlatL2와 IndexFlatIP처럼 인덱스 클래스부터 다르다. 지표를 바꾸려면 인덱스를 새로 만들어야 하고, 벡터가 수천만 건이면 그 재색인이 몇 시간짜리 작업이 된다. 그래서 지표는 나중에 돌려 보는 손잡이가 아니라 처음에 정하는 설계 결정이다.
학습 지표 확인
그럼 무엇을 기준으로 정하는가. 첫 번째로 볼 것은 임베딩 모델이 어떤 지표로 학습됐는지다. 문장 임베딩 모델은 흔히 비슷한 쌍의 점수는 올리고 다른 쌍의 점수는 내리도록 학습하는데, 그 점수를 코사인이나 내적 같은 특정 지표로 계산한다. 모델이 「가깝다」고 배운 방식이 곧 그 지표다. 모델 문서가 권하는 지표가 있으면 그것을 따르는 것이 출발점이다.
함께 볼 것이 출력 벡터의 길이다. 예를 들어 OpenAI의 임베딩 문서는 출력 벡터가 길이 1로 맞춰져 나온다고 적는다. 모든 벡터의 길이가 같으면 노름이 순위에 끼어들 틈이 없어서, 아래 「정규화와 등가」 절에서 보듯 세 지표의 차이가 사라진다. 이 한 줄이 선택의 폭을 크게 바꾼다. 문서에 이런 말이 없으면 벡터 몇 개의 노름을 직접 찍어 본다. 그 방법은 「정규화 누락」 절에 있다.
세 지표
코사인 유사도
코사인 유사도는 두 벡터가 이루는 각도의 코사인이다. 두 벡터의 같은 자리 성분끼리 곱해 모두 더한 값을 두 노름의 곱으로 나눠 구한다. 분자의 그 값에는 따로 이름이 있어서, 이 절의 셋째 지표로 다시 다룬다.
나눗셈이 노름을 지우므로 벡터 하나를 두 배로 늘려도 값이 변하지 않는다. 방향이 같으면 1, 직각이면 0, 정반대면 −1이라 범위가 −1에서 1로 닫혀 있다. 텍스트 임베딩에서는 길이가 뜻과 무관한 요인에 흔들리는 경우가 있어, 방향만 보는 이 지표가 오래 기본 선택 노릇을 했다.
벡터 DB는 흔히 이것을 뒤집어 코사인 거리로 다룬다. 1에서 코사인 유사도를 뺀 값이고 범위는 0에서 2다. 뒤집는 이유는 「거리와 유사도의 부호」 절에서 다룬다.
유클리드 거리
유클리드 거리는 두 벡터 끝점 사이의 직선 거리다. 평면에서 피타고라스 정리로 빗변을 구하는 셈을 차원 수만큼 늘린 것이고, L2 거리라고도 부른다.
같으면 0이고 위로는 끝이 없다. 코사인과 달리 방향이 같아도 길이가 다르면 멀어진다. 원점에서 같은 쪽으로 1만큼 간 점과 10만큼 간 점은 거리가 9다. 크기에 민감하다는 말이 이것이다. 작을수록 가깝다는 점도 코사인 유사도와 반대라서, 두 값을 한 화면에 나란히 보일 때는 방향을 헷갈리기 쉽다.
내적
내적은 두 벡터의 같은 자리 성분끼리 곱해 모두 더한 값이다. 코사인 공식의 분자가 바로 이것이다. 식을 거꾸로 읽으면 내적은 코사인에 두 노름을 곱한 값이다.
방향이 비슷할수록, 그리고 벡터가 길수록 커진다. 부호가 있고 위아래로 상한이 없다. 질의 벡터의 노름은 모든 문서에 똑같이 곱해지므로 순위에 영향을 주지 않지만, 문서 벡터의 노름은 문서마다 달라 그대로 점수에 곱해진다. 이 점이 장점도 되고 사고의 원인도 된다. 가장 큰 내적을 찾는 문제를 따로 MIPS, 최대 내적 탐색이라고 부른다.
손 계산 비교
세 지표를 같은 벡터에 대 보자. 2차원이면 손으로 따라갈 수 있다. 질의는 이고 문서가 셋이다. 은 q와 길이가 5로 같고 방향이 조금 틀어졌다. 는 방향이 더 틀어졌지만 길이가 약 8.25로 가장 길다. 은 q와 방향이 똑같고 길이가 1이다.
내적부터 계산하면 A는 12 + 12 = 24, B는 24 + 8 = 32, C는 1.8 + 3.2 = 5다. 코사인은 여기에 노름의 곱을 나눈다. A는 24를 25로 나눠 0.96, B는 32를 5 × 8.246으로 나눠 약 0.776, C는 5를 5로 나눠 1이다. 유클리드 거리는 차이 벡터의 길이다. q에서 A를 빼면 (−1, 1)이라 √2, 약 1.41이다. B를 빼면 (−5, 2)라 √29, 약 5.39이고, C를 빼면 (2.4, 3.2)라 딱 4다.
줄을 세우면 코사인은 C·A·B, 내적은 B·A·C, 유클리드는 A·C·B다. 같은 벡터에서 1위가 셋 다 다르다. 코사인은 방향이 똑같은 C를 고르고, 내적은 길이가 긴 B를 끌어올리고, 유클리드는 끝점이 가장 가까운 A를 고른다. 어느 순위가 옳은지는 이 예시에서 길이가 무슨 뜻이냐에 달렸다. 길이가 잡음이면 코사인이 옳고, 길이가 「더 중요하다」는 신호면 내적이 옳다.
정규화와 등가
거리와 코사인
벡터를 제 노름으로 나눠 길이를 1로 맞추는 일을 정규화라 하고, 그렇게 얻은 벡터를 단위 벡터라고 부른다. 방향은 그대로 두고 길이 정보만 버리는 조작이다. 정규화하면 세 지표가 한데 모인다. 거리의 제곱을 전개하면 보인다.
두 벡터가 단위 벡터면 두 노름의 제곱이 1이고 내적이 곧 코사인이므로
가 된다. 오른쪽은 코사인이 클수록 작아지는 함수이고 제곱근도 순서를 바꾸지 않으므로, 거리가 작은 순서와 코사인이 큰 순서가 정확히 같다. 앞의 예로 확인해 보자. 정규화한 q는 (0.6, 0.8), A는 (0.8, 0.6)이고, 거리의 제곱은 0.04 + 0.04 = 0.08이다. 2 − 2 × 0.96도 0.08이다. B는 2 − 2 × 0.776으로 약 0.448, C는 방향이 같으니 0이다. 거리 순서가 C·A·B로 코사인과 같아졌다.
내적과 코사인
단위 벡터에서는 내적 공식의 두 노름이 1이라 내적이 곧 코사인이다. 앞의 예에서 정규화 뒤 내적은 C 1.00, A 0.96, B 0.78로, 1위였던 B가 3위로 내려간다. 정규화 전에 B를 끌어올린 것이 순전히 길이였다는 뜻이다.
모든 벡터가 단위 벡터일 때 세 지표는 같은 순위를 낸다. 정규화되지 않은 벡터에서는 그렇지 않다. 첫 전개식에서 은 모든 문서에 공통이라 순위에서 빠지지만 문서의 은 남는다. 그래서 유클리드는 긴 문서를 밀어내고, 내적은 긴 문서를 당긴다. 앞의 예에서 두 지표가 B를 정반대 자리에 둔 이유가 이것이다. 짧은 C가 유클리드에서 2위로 올라선 것도 같은 식으로 설명된다.
계산량 차이
순위가 같다면 남는 차이는 계산량이다. 코사인을 그대로 계산하면 비교할 때마다 두 노름을 구해 나눠야 한다. 벡터를 저장할 때 미리 정규화해 두면 그 계산이 사라지고 내적 한 번만 남는다. 그래서 많은 구현이 코사인을 「정규화한 벡터의 내적」으로 바꿔 계산한다. Qdrant 문서는 코사인 거리를 고르면 벡터를 넣을 때 정규화해 두고 내적으로 계산한다고 적는다.
유클리드도 뺄셈과 제곱이 붙을 뿐 셈의 양은 내적과 비슷하다. 차이는 대개 노름 계산이 비교마다 남느냐에서 오고, 행렬 곱으로 묶어 계산하는 라이브러리에서는 그마저 작다. 어느 쪽이 빠른지는 라이브러리와 하드웨어에 따라 다르므로, 여기에 기댈 만큼 급하다면 자기 환경에서 재 보는 것이 맞다. 아래는 앞의 손 계산을 그대로 옮긴 검산이다.
import numpy as np
q = np.array([3.0, 4.0])
docs = {"A": np.array([4.0, 3.0]), "B": np.array([8.0, 2.0]), "C": np.array([0.6, 0.8])}
def unit(v):
return v / np.linalg.norm(v)
for name, d in docs.items():
cos = q @ d / (np.linalg.norm(q) * np.linalg.norm(d))
l2 = np.linalg.norm(q - d)
unit_l2_sq = np.linalg.norm(unit(q) - unit(d)) ** 2
print(name, f"dot={q @ d:.1f} cos={cos:.3f} l2={l2:.2f}",
f"unit_l2^2={unit_l2_sq:.3f} 2-2cos={2 - 2 * cos:.3f}")
# A dot=24.0 cos=0.960 l2=1.41 unit_l2^2=0.080 2-2cos=0.080
# B dot=32.0 cos=0.776 l2=5.39 unit_l2^2=0.448 2-2cos=0.448
# C dot=5.0 cos=1.000 l2=4.00 unit_l2^2=0.000 2-2cos=0.000
거리와 유사도의 부호
최소화 인덱스
ANN 인덱스는 대부분 「가장 작은 값」을 찾는 문제로 짜여 있다. 그래프를 따라 더 가까운 이웃으로 옮겨 가는 방식이든 가까운 군집부터 뒤지는 방식이든, 내부에서는 값이 작아지는 쪽으로 움직인다. 그래서 「클수록 가깝다」인 유사도는 인덱스에 들어갈 때 「작을수록 가깝다」인 거리로 바뀐다. 코사인 유사도는 1에서 빼서 코사인 거리가 되고, 내적은 부호를 뒤집어 음의 내적이 된다.
음의 내적은 엄밀한 뜻의 거리가 아니다. 자기 자신과의 값이 0이 아니고 음수가 되기도 한다. 그래도 「작을수록 가깝다」는 약속은 지키므로 인덱스는 그대로 돈다. 코사인 거리도 삼각 부등식을 지키지 않아 엄밀한 거리는 아니지만, 쓰는 쪽에서 신경 쓸 것은 방향 하나다.
pgvector 연산자
pgvector는 이 변환을 연산자에 그대로 드러낸다. <->는 유클리드 거리, <=>는 코사인 거리, <#>는 음의 내적을 돌려준다. 셋 다 작을수록 가깝다. <#>가 음수로 오는 이유는 pgvector 문서가 직접 밝힌다 — PostgreSQL이 연산자에 대한 인덱스 탐색을 오름차순으로만 지원하기 때문이다. 내적이 큰 순으로 뽑으려면 부호를 뒤집어 작은 순 정렬로 바꿔야 한다. 화면에 유사도 점수를 보이고 싶으면 <#>의 결과에 −1을 곱하고, 코사인 유사도는 1에서 <=>의 결과를 뺀다.
앞의 예를 그대로 넣으면 아래와 같다. 주석의 수는 손 계산과 같다.
-- docs 테이블에 A(4,3) · B(8,2) · C(0.6,0.8), 질의 q = (3,4)
SELECT id,
embedding <#> '[3,4]' AS neg_ip, -- A -24, B -32, C -5
-(embedding <#> '[3,4]') AS ip, -- A 24, B 32, C 5
1 - (embedding <=> '[3,4]') AS cos_sim -- A 0.96, B 0.78, C 1.00
FROM docs
ORDER BY embedding <#> '[3,4]' -- 오름차순: B, A, C
LIMIT 3;
정렬 방향 버그
이 부호 약속을 놓치면 전형적인 버그가 생긴다. 코사인 거리를 유사도로 착각해 내림차순으로 정렬하면 가장 먼 문서가 1위로 온다. 에러는 없고 결과만 거꾸로다. 문서가 많으면 1위가 전혀 무관한 글이라 금방 드러나지만, 미리 걸러 둔 후보 몇 개를 다시 줄 세우는 자리라면 모두 어느 정도 관련이 있어 한참 동안 눈에 안 띈다.
pgvector라면 속도로도 드러난다. 인덱스는 거리 연산자의 오름차순 정렬에 쓰이므로, 내림차순으로 쓰면 인덱스를 못 타고 전체를 훑는다. 결과가 이상한데 질의까지 느려졌다면 정렬 방향부터 본다. 반대 방향의 실수도 있다. 음의 내적을 점수로 그대로 보여 주면 「가장 관련 높은 문서의 점수가 가장 낮다」는 보고가 들어온다. 새 DB나 라이브러리로 옮길 때는 연산자가 거리를 돌려주는지 유사도를 돌려주는지, 부호가 어느 쪽인지를 문서에서 먼저 확인한다.
정규화 누락
조용한 저하
코사인을 고르면 정규화 걱정이 없을 것 같지만, 코사인을 정규화된 벡터의 내적으로 계산하는 도구라면 이야기가 다르다. 정규화를 도구가 해 주는지, 넣는 쪽이 해야 하는지는 도구마다 다르다. Faiss가 대표적이다. 코사인을 쓰려면 faiss.normalize_L2로 벡터를 직접 정규화한 뒤 IndexFlatIP 같은 내적 인덱스에 넣어야 내적이 코사인이 된다.
이 단계를 빠뜨리면 인덱스는 그냥 내적 검색을 하고, 에러 없이 결과가 돌아온다. 품질만 조금씩 나빠지므로 한동안 알아채기 어렵다. 흔한 사례가 색인 코드와 질의 코드를 다른 사람이 짜서 한쪽만 정규화한 경우다. 질의 쪽만 빠뜨린 경우는 해가 없다. 질의의 노름은 모든 문서에 똑같이 곱해질 뿐이라 순위를 바꾸지 않는다. 문제는 문서 쪽이다.
노름의 독차지
정규화되지 않은 문서 벡터로 내적 검색을 하면 노름이 큰 문서가 상위를 차지한다. 앞의 손 계산에서 방향이 가장 틀어진 B가 길이 하나로 1위에 오른 것이 바로 그 모양이다. 어떤 성질을 따라 노름이 커지는지는 모델과 데이터마다 다르다. 그 성질이 문서 길이나 형식처럼 뜻과 무관한 것이라면, 특정 문서 몇 개가 온갖 질의의 상위에 반복해서 나타난다.
증상은 알아보기 쉽다. 질의가 달라도 늘 같은 문서가 1~3위에 보인다면 노름부터 의심할 자리다. 그 문서들의 노름을 찍어 보면 나머지보다 눈에 띄게 크다. 유클리드에서는 거꾸로 노름이 작은 문서가 유리해진다. 앞의 예에서 짧은 C가 유클리드 2위로 올라온 것처럼, 길이가 유난히 짧은 벡터가 여기저기 끼어든다.
노름 분포 점검
색인 전에 할 점검은 간단하다. 벡터 몇천 개의 노름을 뽑아 최솟값·중앙값·최댓값을 찍어 본다.
# embeddings: (N, d) 크기의 numpy 배열
norms = np.linalg.norm(embeddings, axis=1)
print(norms.min(), np.median(norms), norms.max())
셋이 모두 1에 아주 가까우면 이미 정규화돼 있으니 세 지표 중 무엇을 골라도 순위가 같다. 퍼져 있으면 두 갈래다. 길이가 뜻이 아니라면 정규화한 뒤 넣고, 길이에 뜻을 담으려는 설계라면 다음 절처럼 그 뜻을 분명히 하고 내적을 쓴다. 이 점검은 임베딩 모델을 바꿀 때마다 다시 돌린다. 같은 회사의 모델이라도 출력을 정규화하는지는 다를 수 있고, 같은 모델이라도 라이브러리 옵션 하나로 달라지기도 한다.
크기의 의미
인기도와 내적
크기가 뜻을 가질 때도 있다. 대표적인 것이 추천이다. 사용자와 아이템을 같은 공간의 벡터로 만들어 두 벡터의 내적을 선호 점수로 쓰는 방식이 널리 쓰인다. 학습은 이 내적이 실제 선호를 맞히도록 벡터를 조정하므로, 많은 사람이 좋아한 아이템일수록 벡터가 길어지는 식으로 노름에 인기도 같은 정보가 스며들 수 있다.
이런 모델에서 정규화하고 코사인을 쓰면 학습이 담아 둔 정보를 버리는 셈이다. 모델이 학습한 지표인 내적으로 검색하는 것이 맞다. 「학습 지표 확인」 소절과 같은 원칙이다. 다만 인기 아이템이 모든 사용자의 상위를 차지하는 것이 원하는 결과인지는 따로 따져야 한다. 원치 않는다면 지표를 바꾸기보다 결과 단계에서 다양성을 조정하는 편이 흔하다.
군집화
군집화도 크기를 쓴다. k-평균은 점들을 k개의 무리로 나누되, 각 점과 그 무리 중심 사이 유클리드 거리 제곱의 합이 가장 작아지도록 중심을 옮겨 가는 알고리즘이다. 목적 함수 자체가 유클리드 거리라서 이 자리에서는 거리가 곧 정의다.
텍스트 임베딩을 방향으로 묶고 싶다면 먼저 정규화한 뒤 k-평균을 돌리는 방법이 흔하다. 단위 벡터에서는 거리 제곱이 2 − 2cos였으므로 방향이 비슷한 것끼리 묶인다. ANN 인덱스 가운데 벡터를 군집으로 나눠 가까운 군집만 뒤지는 방식도 내부에서 이런 군집화를 쓰는데, 그 이야기는 다음 글에서 한다.
이중 저장
같은 데이터를 두 목적으로 쓸 때는 벡터를 두 벌 두는 것이 깔끔하다. 원본 벡터는 크기가 뜻을 가지는 추천 점수나 분석에 쓰고, 정규화한 사본은 코사인 검색 인덱스에 넣는다. 저장 공간은 늘지만 한 인덱스에 두 가지 뜻을 억지로 싣는 것보다 버그가 적다. pgvector라면 열을 두 개 두고 각 열에 제 연산자 클래스로 인덱스를 따로 만들면 된다.
한 벌만 둔다면 그 벌이 정규화된 것인지를 열 이름이나 스키마 주석에 적어 둔다. 앞 절의 조용한 저하는 대개 이 기록이 없어서 생긴다. 넣는 코드가 정규화를 가정하는지, 읽는 코드가 그것을 아는지가 문서 한 줄로 이어져 있어야 한다.
지표 고르기
골든셋 비교
마지막 판단은 자기 데이터로 한다. 골든셋은 질의마다 정답 문서를 사람이 표시해 둔 작은 평가용 묶음이다. 질의 수십~수백 개면 시작하기에 충분하다. 절차는 셋이다.
- 골든셋의 질의와 문서를 임베딩한다.
- 세 지표로 각각 전수 탐색을 해 상위 10을 뽑는다. ANN을 끼우면 근사 오차가 지표 차이와 섞이므로 이 비교에서는 빼 둔다.
- 지표 쌍마다 상위 10의 겹침을 세고, 정답 문서가 상위 10에 든 비율을 지표마다 센다.
겹침이 거의 10개 중 10개면 사실상 같은 지표이므로 다음 단계로 간다. 겹침이 크게 떨어지는 질의가 있으면 그 질의만 꺼내 어느 쪽 결과가 맞는지 눈으로 본다. 노름이 큰 특정 문서가 한쪽에서만 반복해 올라온다면 「정규화 누락」 절의 문제다.
결정 시점
이 비교는 색인 전에 한다. 지표를 바꾸려면 재색인이 필요하고, 데이터가 쌓일수록 그 값이 커진다. 임베딩 모델을 바꿀 때는 어차피 전체를 다시 임베딩하고 재색인하므로, 지표 재검토를 그때 함께 묶으면 비용이 가장 싸다. 정한 뒤에는 그 결정을 인덱스 정의 옆에 적어 둔다. 왜 이 지표인지, 벡터가 정규화돼 있는지 두 줄이면 된다.
선택 기준
정규화된 벡터라면 세 지표의 순위가 같으므로 가장 빠르고 도구가 잘 받쳐 주는 것을 고르면 된다. 대개 정규화된 벡터의 내적이거나, 속에서 그것을 쓰는 코사인 설정이다. 정규화되지 않은 벡터라면 먼저 모델이 어떤 지표로 학습됐는지를 따르고, 길이가 뜻인지 잡음인지를 노름 분포로 확인한다. 길이가 잡음이면 정규화하고, 뜻이면 내적을 쓴다. 유클리드는 k-평균처럼 절대 거리가 목적인 자리에서 제 몫을 한다. 어느 경우든 근거는 남의 벤치마크가 아니라 자기 골든셋에서 센 겹침이다.
지표를 정했으면 남는 문제는 속도다. 수백만 벡터와 매번 값을 재는 대신 후보를 줄여 빨리 찾는 방법이 필요하다. 다음 글에서는 그 방법인 ANN 알고리즘 셋을 다룬다. 이웃 그래프를 타고 좁혀 가는 HNSW, 벡터를 군집으로 나눠 가까운 군집만 뒤지는 IVF, 해시로 비슷한 벡터를 한 칸에 모으는 LSH다.
읽어주셔서 감사합니다. 😊

