7장. 자체 임베딩과 벡터DB, 검색 품질
의미 검색
도서관 사서에게 "잊어버린 비밀번호를 찾는 법"을 물으면, 사서는 낱말 하나하나보다 무엇을 원하는지를 생각해 책을 찾습니다. 의미 검색은 질문과 문서를 모두 임베딩으로 바꿔, 뜻이 가까운 조각을 찾습니다.
1번과 2번은 미리 한 번 해 두는 일입니다. 문서를 조각으로 나누고 조각마다 임베딩을 만들어 둡니다. 3번부터는 질문이 올 때마다 합니다. 질문을 임베딩으로 바꾸고, 4번에서 모든 조각과의 코사인 유사도를 재고, 5번에서 높은 순서로 k 개를 고릅니다. 여기서 사서가 기억한 책 내용이 조각 임베딩, 손님의 물음이 질문 임베딩입니다.
e5 모델의 약속: query 와 passage
multilingual-e5 모델은 학습할 때 질문 앞에 query: , 문서 앞에 passage: 를 붙였습니다. 쓸 때도 똑같이 붙여야 제 성능이 납니다. 모델마다 이런 약속이 다르니 모델 카드(설명 페이지)를 읽습니다.
| 넣는 글 | 앞에 붙이는 말 | 예 |
|---|---|---|
| 문서 조각 | passage: | passage: 사내 계정 비밀번호는 90일마다 바꿔야 합니다. |
| 질문 | query: | query: 암호는 얼마나 자주 새로 정해야 하나요? |
가장 가까운 k 개 고르기
조각 임베딩을 행렬로 쌓아 두면, 질문 하나와의 유사도는 행렬 곱 한 번으로 모두 구합니다.
import numpy as np
rng = np.random.default_rng(0)
E = rng.normal(size=(30, 384)) # 조각 30개의 임베딩(설명용 난수)
E /= np.linalg.norm(E, axis=1, keepdims=True) # 조각마다 길이 1 로
q = E[7] + 0.03 * rng.normal(size=384) # 7번 조각과 닮은 질문을 흉내 낸다
q /= np.linalg.norm(q)
scores = E @ q # 결과: 조각 30개의 코사인 유사도
top = np.argsort(-scores)[:3] # 큰 순서로 3개
print(top, scores[top].round(3))7번 조각이 0.862 로 1등이고, 2등과 3등은 0.138, 0.120 입니다. 조각이 수만 개가 되면 이 곱셈이 느려지는데, 그때 벡터DB 의 인덱스가 필요합니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
np.random.default_rng(0) | 씨앗 0 으로 난수 생성기를 만든다. rng 는 random number generator(난수 생성기)의 줄임말이다. 다시 돌려도 같은 난수가 나온다 |
rng.normal(size=(30, 384)) | 30줄 × 384칸의 난수 표를 만든다. 줄 하나가 조각 하나의 임베딩 흉내다 |
np.linalg.norm(E, axis=1, keepdims=True) | 줄마다 길이를 잰다. axis=1 은 "줄 안의 칸을 따라", keepdims=True 는 결과를 (30, 1) 모양으로 남겨 나눗셈이 줄마다 맞게 되도록 한다 |
E @ q | 표의 줄마다 질문 벡터와 내적한다. 조각 30개의 유사도가 한 번에 나온다 |
np.argsort(-scores)[:3] | 값을 작은 순서로 줄 세운 번호를 돌려주는 argsort 에 빼기를 붙여 큰 순서로 바꾸고, 앞의 3개를 자른다 |
이 코드가 뜻하는 것
도서관 사서가 손님의 물음을 듣고, 머릿속의 책 30권과 하나하나 견주어 가장 가까운 3권을 고르는 장면입니다. 여기서 책 30권이 조각 임베딩 E, 손님의 물음이 질문 벡터 q 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
E = rng.normal(...) | 조각 30개의 임베딩을 흉내 낸다 | 책 30권의 내용 기억 |
E /= np.linalg.norm(...) | 조각마다 길이를 1 로 맞춘다 | 바늘 길이를 같게 자른다 |
q = E[7] + 0.03 * ... | 7번 조각에 작은 잡음을 더해 질문을 만든다 | 7번 책과 거의 같은 내용을 묻는 손님 |
scores = E @ q | 30개 조각과의 유사도를 한 번에 구한다 | 30권과 한꺼번에 견준다 |
top = np.argsort(-scores)[:3] | 점수가 큰 3개의 번호를 고른다 | 가장 가까운 책 3권을 뽑는다 |
7번 조각이 0.862 로 2등(0.138)보다 훨씬 높게 나온 것은, 질문을 7번 조각에서 만들었기 때문입니다. 실제 검색에서는 정답이 이렇게 뚜렷하게 앞서지 않습니다.
작게 줄여서 눈으로 보기
난수 대신 조각 4개에 이름을 붙이고, 2차원 벡터를 손으로 정해 위 k 개를 골라 봅니다. 질문은 "암호를 잊었어요"라고 치고 벡터도 손으로 정했습니다.
import numpy as np
docs = ["비밀번호 바꾸기", "계정 잠김", "프린터 고장", "와이파이 연결"]
E = np.array([[0.9, 0.1], [0.8, 0.4], [0.1, 0.9], [0.3, 0.7]]) # 조각 4개의 2차원 임베딩(설명용)
E = E / np.linalg.norm(E, axis=1, keepdims=True)
q = np.array([0.85, 0.3]) # 질문 "암호를 잊었어요"(설명용)
q = q / np.linalg.norm(q)
scores = E @ q
print("유사도:", scores.round(3))
order = np.argsort(-scores)
print("큰 순서의 번호:", order)
for rank, i in enumerate(order[:2], start=1):
print(f"{rank}등 {docs[i]} {scores[i]:.3f}")유사도: [0.974 0.992 0.435 0.677]
큰 순서의 번호: [1 0 3 2]
1등 계정 잠김 0.992
2등 비밀번호 바꾸기 0.974| 결과 | 읽는 법 |
|---|---|
| 유사도 네 개 | 조각 순서대로의 점수입니다. 비밀번호와 계정 쪽(0.974, 0.992)이 높고 프린터(0.435)가 가장 낮습니다 |
| 큰 순서의 번호 [1 0 3 2] | 1번(계정 잠김), 0번, 3번, 2번 순서로 가깝다는 뜻입니다. 점수가 아니라 번호가 나옵니다 |
| 1등, 2등 | 번호를 docs 에 넣어 이름으로 바꿨습니다. k 가 2 라서 두 개만 골랐습니다 |
argsort 는 점수가 아니라 번호를 돌려준다는 점을 기억해 둡니다. 번호로 원문 목록을 찾아가야 사람이 읽을 결과가 됩니다.
주의할 점
- e5 처럼 학습된 임베딩은 서로 관련 없는 글끼리도 유사도가 0.8 안팎으로 높게 나옵니다. 점수의 크기보다 순서를 봅니다. 실습 1에서 실제 값을 봅니다.
- 의미 검색은 "VPN-809" 같은 코드나 드문 고유 이름에 약할 수 있습니다. 그럴 때 키워드 검색을 섞습니다("하이브리드 검색" 절).