임베딩 — 의미를 좌표로 바꾸는 가장 단순한 방법
한 줄 요약
임베딩은 텍스트를 벡터로 바꿔 거리로 비교할 수 있게 만드는 일이다. 신경망 없이 TF-IDF 만으로도 그 뼈대, 곧 벡터화 → 정규화 → 내적 과 그 뼈대가 무너지는 방식을 전부 관찰할 수 있다.
왜 이게 필요했나
고객 지원 검색창에 두 가지 신고가 들어왔다고 하자. 하나는 "결제가 느려요" 로 검색하면 "결제 지연 대응" 문서가 안 나온다는 것이고, 다른 하나는 오류 코드 E4012 로 검색하면 엉뚱한 문서가 먼저 나온다는 것이다. 첫 번째는 표면 글자가 달라서 생긴 실패이고, 두 번째는 글자가 정확히 같아야 하는데 의미만 비슷한 것을 가져와서 생긴 실패다. 두 실패를 구별해 고치려면 "비슷하다" 를 수치로 어떻게 정의했는지부터 알아야 한다.
텍스트를 벡터로 바꿔 두면 내적이나 각도로 유사도를 계산할 수 있다. 가장 단순한 벡터는 단어 등장 횟수를 나열한 것이다. 문제는 흔한 단어가 지배한다는 점이다. 모든 문서에 나오는 단어는 문서를 구분하는 데 아무 도움이 안 되는데 값은 가장 크다.
TF-IDF 는 이 문제를 정면으로 다룬다. 단어의 문서 내 빈도(TF)에 문서 빈도의 역수(IDF)를 곱한다. 여러 문서에 골고루 나오는 단어는 IDF 가 낮아 눌리고, 특정 문서에만 나오는 단어는 IDF 가 높아 강조된다.
어떻게 동작하나
평활을 넣은 IDF 는 보통 이렇게 정의한다. scikit-learn 의 TfidfTransformer 가 기본값으로 쓰는 식과 같다.
idf(t) = ln((1 + N) / (1 + df(t))) + 1
분모와 분자에 1을 더하는 것은 모든 항을 한 번씩 담은 문서가 하나 더 있다고 치는 것으로, df 가 0 인 항에서도 나눗셈이 깨지지 않게 한다. 마지막에 1을 더하는 것은 모든 문서에 나오는 항의 가중치가 0 이 되어 완전히 사라지는 것을 막기 위해서다. 숫자로 보면 감이 온다. 문서가 30건일 때 한 문서에만 나오는 항은 약 3.741, 세 문서에 나오는 항은 약 3.048, 열 문서에 나오는 항은 약 2.036, 모든 문서에 나오는 항은 정확히 1이다. 드문 항이 흔한 항보다 서너 배 무겁게 취급된다.
벡터를 만든 뒤에는 L2 정규화를 한다. 각 행을 자기 길이로 나눠 길이를 1로 맞추면 두 벡터의 내적이 곧 코사인 유사도가 되고, 문서 길이의 영향이 사라진다. 정규화하지 않으면 긴 문서는 단지 단어가 많다는 이유로 거의 모든 질의와 내적이 커진다.
해싱 트릭은 어휘 사전을 유지하는 대신 항을 해시 함수로 고정 개수의 버킷에 배정한다. 어휘가 아무리 늘어도 차원이 고정되고 사전을 들고 다니지 않아도 된다. 대가는 두 가지다. 서로 다른 항이 같은 버킷에 들어가면 구분할 수 없고, 해시는 한 방향이라 버킷 번호에서 원래 항을 되찾을 수 없다. scikit-learn 의 HashingVectorizer 는 기본으로 2의 20제곱 개의 버킷을 쓰고, 해시 값에 따라 부호를 번갈아 붙여 충돌이 한쪽으로 쌓이지 않고 서로 상쇄되게 한다. 버킷을 1024개처럼 작게 잡으면 충돌이 눈에 보일 만큼 많아지고, 그 대가가 최근접 이웃이 바뀌는 모습으로 드러난다.
신경망 임베딩도 뼈대는 같다. 차이는 벡터를 만드는 방법뿐이다. 단어 횟수 대신 학습된 모델이 문장을 수백 차원의 조밀한 벡터로 바꾸고, 그래서 "느리다" 와 "지연이 크다" 가 가까워진다. 정규화하고 내적으로 비교한다는 나머지 절차는 그대로다.
현장에서 무엇이 잘못되나
정규화를 빠뜨린다. 증상은 특정 문서 몇 개가 서로 무관한 질의 대부분의 상위에 등장하는 것이다. 보통 가장 긴 문서들이다. 결과 목록에서 같은 문서 id 가 반복해서 보이면 행 길이부터 확인한다.
질의가 영벡터가 된다. 질의의 단어가 전부 색인 어휘 밖에 있으면 질의 벡터는 모든 값이 0 이다. 여기서 길이로 나누면 0 을 0 으로 나누게 되어 값이 전부 nan 이 되고, numpy 는 경고 한 줄만 남긴 채 계산을 이어 간다. nan 이 섞인 점수로 정렬하면 순서가 의미 없어지는데 오류는 나지 않는다. 길이가 0 인 질의는 정규화 전에 걸러 "결과 없음" 으로 돌려준다.
색인과 질의의 규칙이 다르다. 색인 때는 소문자로 바꿨는데 질의 때는 안 바꿨다거나, 질의가 들어올 때마다 IDF 를 다시 계산했다면 두 벡터는 서로 다른 공간에 있다. 점수가 나오기는 하므로 알아채기 어렵다. 신경망 임베딩에서는 이 문제가 더 크다. 모델이 바뀌면 벡터를 비교할 수 없으므로 전체를 다시 색인해야 하고, 일부 모델은 질의와 문서에 서로 다른 접두어를 붙이도록 학습되어 있어(예: E5 계열의 query: 와 passage:) 그것을 빼먹으면 품질이 조용히 떨어진다.
해시가 실행마다 바뀐다. 파이썬 내장 hash() 는 문자열에 대해 프로세스마다 다른 무작위 값을 섞는다. 이것으로 버킷을 정하면 어제 만든 색인과 오늘 들어온 질의가 서로 다른 버킷을 쓴다. 재현 가능한 해시(hashlib.md5 등)를 써야 한다.
점수 임계값을 그대로 옮긴다. TF-IDF 코사인 0.3 과 신경망 임베딩 코사인 0.3 은 같은 뜻이 아니다. 모델마다 점수 분포가 다르므로, 모델이나 가중치 방식을 바꾸면 임계값도 평가 집합으로 다시 정한다.
어떻게 확인하나
행렬을 만들었다면 계산을 믿기 전에 불변 조건부터 확인한다.
import numpy as np
X = np.load("tfidf.npy")
norms = np.linalg.norm(X, axis=1)
print(np.allclose(norms, 1.0)) # every row has length 1
print(np.isnan(X).any()) # no nan anywhere
S = X @ X.T
print(np.allclose(np.diag(S), 1.0)) # self-similarity is 1
np.fill_diagonal(S, -1)
nn = S.argmax(axis=1)
print(np.bincount(nn).max()) # one document as everyone's neighbour?
마지막 줄이 특히 쓸모 있다. 각 문서의 최근접 이웃을 모아 한 문서가 몇 번이나 "누군가의 가장 가까운 이웃" 이 되는지 센다. 한 문서가 유난히 자주 뽑히면 정규화나 가중치에 문제가 있다는 신호다.
해싱 트릭의 손실은 두 가지 숫자로 잰다. 서로 다른 항의 수와 실제로 쓰인 버킷의 수를 비교하면 충돌이 얼마나 되는지 보이고, 정확한 행렬과 해시 행렬의 최근접 이웃이 몇 건 일치하는지 세면 그 충돌이 검색 결과를 얼마나 바꿨는지 보인다. 그리고 검색 결과를 확인할 때는 점수만 보지 말고 왜 그 문서가 나왔는지 를 본다. 질의와 문서가 공유하는 항과 그 가중치를 찍어 보면 대개 원인이 한눈에 드러난다.
다음 실습에서 할 것
모듈의 실습은 둘이다. 바로 다음 실습은 앞 이론에서 다룬 BPE 토크나이저를 바닥부터 구현하는 것이고, 그 뒤의 실습에서 이 이론의 내용을 손으로 계산한다. 문서 30건을 토큰화해 문서 빈도, IDF, TF-IDF 행렬을 numpy 로 직접 만들고, 코사인 유사도로 최근접 이웃과 질의 검색을 수행하고, 마지막에는 해싱 트릭으로 1024차원 행렬을 만들어 최근접 이웃이 얼마나 달라지는지 잰다.