7장. 자체 임베딩과 벡터DB, 검색 품질
실습 1 — 문장을 임베딩으로 바꾸기
sentence-transformers 로 intfloat/multilingual-e5-small 을 불러 문장을 임베딩으로 바꾸고, 문장끼리 유사도를 견줍니다. 처음 실행하면 모델 파일(470MB 쯤)을 내려받습니다. 이 장은 임베딩과 재정렬 모델을 device="cpu" 로 CPU 에 올립니다. 조각 수십 개는 CPU 로도 1초 안팎이고, GPU 는 프로젝트의 Ollama 모델에 비워 둡니다.
1단계. 모델 불러오기
from sentence_transformers import SentenceTransformer
# 처음 한 번은 인터넷에서 내려받고, 다음부터는 저장된 파일을 쓴다
# device="cpu": GPU 가 있어도 CPU 에서 돌린다
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
print("차원:", emb.get_sentence_embedding_dimension())
print("파라미터 수:", f"{sum(p.numel() for p in emb.parameters()):,}")차원: 384
파라미터 수: 117,653,7602단계. 문장 넷을 임베딩하기
sentences = ["passage: 사내 계정 비밀번호는 90일마다 바꿔야 합니다.",
"passage: 비밀번호를 5번 틀리면 계정이 30분 동안 잠깁니다.",
"passage: 층마다 복합기가 있고, 이름은 층 번호를 따릅니다.",
"passage: 손님용 와이파이 비밀번호는 매주 월요일에 바뀝니다."]
# normalize_embeddings=True: 길이를 1 로 맞춰, 내적이 곧 코사인 유사도가 되게 한다
E = emb.encode(sentences, normalize_embeddings=True)
print("모양(문장 수, 차원):", E.shape)
print("첫 문장의 앞 숫자 5개:", E[0][:5].round(3))
print("길이:", round(float((E[0] ** 2).sum() ** 0.5), 3))모양(문장 수, 차원): (4, 384)
첫 문장의 앞 숫자 5개: [ 0.04 -0.023 -0.024 -0.055 0.089]
길이: 1.03단계. 질문과 가까운 문장 찾기
query = emb.encode("query: 암호는 얼마나 자주 새로 정해야 하나요?", normalize_embeddings=True)
scores = E @ query # 결과: 문장 넷과의 코사인 유사도
for s, text in sorted(zip(scores, sentences), reverse=True):
print(f"{s:.3f} {text.removeprefix('passage: ')}")0.843 사내 계정 비밀번호는 90일마다 바꿔야 합니다.
0.823 손님용 와이파이 비밀번호는 매주 월요일에 바뀝니다.
0.818 비밀번호를 5번 틀리면 계정이 30분 동안 잠깁니다.
0.813 층마다 복합기가 있고, 이름은 층 번호를 따릅니다.4단계. 접두어를 빼면
# query:, passage: 를 빼고 임베딩해 순위와 점수가 어떻게 바뀌는지 본다
bare = emb.encode([s.removeprefix("passage: ") for s in sentences], normalize_embeddings=True)
bare_q = emb.encode("암호는 얼마나 자주 새로 정해야 하나요?", normalize_embeddings=True)
for s, text in sorted(zip(bare @ bare_q, sentences), reverse=True):
print(f"{s:.3f} {text.removeprefix('passage: ')}")0.863 사내 계정 비밀번호는 90일마다 바꿔야 합니다.
0.838 손님용 와이파이 비밀번호는 매주 월요일에 바뀝니다.
0.831 비밀번호를 5번 틀리면 계정이 30분 동안 잠깁니다.
0.823 층마다 복합기가 있고, 이름은 층 번호를 따릅니다.이 실습이 뜻하는 것
이 실습은 문장마다 지도 위의 주소를 매겨, 뜻이 가까운 문장끼리 가까운 곳에 놓이게 하는 것과 같습니다. 주소를 매겨 주는 측량사를 부르고(1단계), 문장 넷에 주소를 매기고(2단계), 질문에도 주소를 매겨 가까운 문장부터 줄 세우고(3단계), 주소를 매길 때 붙이던 "질문용", "문서용" 꼬리표를 떼면 어떻게 되는지 봅니다(4단계). 여기서 측량사가 multilingual-e5-small 임베딩 모델, 주소가 숫자 384개로 된 임베딩, 가까운 정도가 코사인 유사도, 꼬리표가 query: , passage: 접두어입니다. 비유와 다른 점은, 실제 지도는 가로세로 2차원이지만 임베딩은 384개 방향을 가진 공간이라 눈으로 그릴 수 없다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 모델 | 차원: 384 | 문장 하나를 숫자 384개로 나타냅니다 |
| 1. 크기 | 파라미터 수: 117,653,760 | 모델 안의 학습된 숫자가 1억 1,765만 개쯤입니다. 6장에서 쓴 0.5B(약 5억 개) 대화 모델보다 작습니다 |
| 2. 모양 | (4, 384) | 문장 4개가 각각 숫자 384개가 되었습니다 |
| 2. 앞 숫자 | [ 0.04 -0.023 …] | 숫자 하나하나에는 사람이 읽을 뜻이 없습니다. 384개가 모여 위치 하나를 나타냅니다 |
| 2. 길이 | 길이: 1.0 | 길이를 1로 맞춰 두어, 두 줄을 칸끼리 곱해 더한 값(내적)이 곧 코사인 유사도가 됩니다 |
| 3. 1등 | 0.843 사내 계정 비밀번호는 90일마다… | "암호"와 "비밀번호"는 글자가 하나도 겹치지 않는데도, 뜻이 맞는 문장을 1등에 올렸습니다 |
| 3. 나머지 | 0.823, 0.818, 0.813 | 상관없는 복합기 문장도 0.8 을 넘습니다. 점수의 크기보다 순서를 봐야 합니다 |
| 4. 접두어를 빼면 | 0.863, 0.838, 0.831, 0.823 | 순서는 같고 점수만 0.01~0.02 쯤 올랐습니다. 문장 넷으로는 접두어의 효과를 알 수 없습니다 |
한 줄로 줄이면, 임베딩은 글자가 달라도 뜻이 가까운 문장을 찾아 준다는 것을 문장 넷으로 확인한 실습입니다. 실습 2에서 이 방법을 사내 문서 30조각에 써서 의미 검색을 만듭니다. 문장 넷에서 1등을 맞혔다고 검색이 잘 된다고 말할 수는 없습니다. 1등과 2등의 차이(0.843 과 0.823)가 작아서 문서가 많아지면 순서가 바뀔 수 있습니다.
코드 설명
SentenceTransformer("모델 이름")은 Hugging Face 에서 임베딩 모델을 불러오고,encode()는 문장 목록을 (문장 수, 차원) 모양의 NumPy 배열로 바꿉니다.- 모델은 384차원, 파라미터 1억 1,765만 개입니다. 문장 넷은 (4, 384) 배열이 되고,
normalize_embeddings=True덕분에 길이가 1.0 입니다. 숫자 하나하나(0.04, -0.023 …)에는 읽을 뜻이 없습니다. - "암호는 얼마나 자주 새로 정해야 하나요?"에 "비밀번호는 90일마다 바꿔야"가 0.843 으로 1등입니다. "암호"와 "비밀번호"는 글자가 하나도 겹치지 않는데도 찾았습니다. 다만 와이파이 비밀번호(0.823), 계정 잠김(0.818), 복합기(0.813)도 모두 0.8 을 넘어, 점수 차이가 작습니다. 본문에서 말한 대로 크기보다 순서를 봅니다.
- 접두어를 빼도 이 네 문장에서는 순서가 같았고, 점수가 모두 0.02 쯤 올랐습니다. 접두어는 모델이 학습할 때의 약속이라 지키고, 효과가 있는지는 문장 넷이 아니라 실습 4 같은 질문 묶음으로 재야 압니다.