문장을 숫자로 바꾼다 — 낱말이 안 겹쳐도 찾으려면
Article
앞 편에서 데이터를 꺼내는 법을 배웠다. 그런데 꺼낼 수 있는 것과 없는 것이 갈린다.
된다
조건이 딱 떨어진다
price <= 30000category = '토너'tags LIKE '%선물용%'- 숫자와 정해진 낱말
안 된다
뜻으로 물어보면
- 「건조한 피부에 순한 것」
- 「여드름 올라올 때 쓸 것」
LIKE '%순한%'로 하면 그 낱말이 든 상품만 걸린다- 「자극 없는」이라고 적힌 상품은 못 찾는다
이번 편에서 그 벽을 넘는다. 모델은 아직 안 나온다 — 문장을 숫자로 바꾸는 일과 말을 지어내는 일은 완전히 다른 물건이 한다.
1. 도구를 깐다 — 전역으로
python -m pip install sentence-transformers
python -m pip 로 부르는 이유가 있다. 그냥 pip 라고 하면 컴퓨터에 파이썬이 여러 개 깔려 있을 때 엉뚱한 쪽에 설치될 수 있다. python -m 을 앞에 붙이면 「지금 이 파이썬에 깔아라」는 뜻이 된다.
깔렸는지 확인한다
python -m pip show sentence-transformers
- Name: sentence-transformers
- Version: 5.6.1
- Location: C:\\Python313\\Lib\\site-packages
2. 모델을 받는다
도구를 깔았다고 모델이 온 것은 아니다. 모델은 처음 쓸 때 자동으로 받아온다.
# sentence_transformers 안의 SentenceTransformer 를 꺼내 쓴다.
# 파일 이름은 밑줄(sentence_transformers), 설치 이름은 하이픈(sentence-transformers)이다.
# 헷갈리기 쉬운데 파이썬 세계의 흔한 규칙이다
from sentence_transformers import SentenceTransformer
# 괄호 안이 모델 이름이다. 인터넷에 공개된 모델의 주소라고 보면 된다.
# intfloat 만든 곳
# multilingual-e5-small 여러 나라 말을 다루는, e5 계열의 작은 모델
#
# 처음 실행하면 이 줄에서 모델을 받아온다 (약 470MB · 몇 분).
# 두 번째부터는 컴퓨터에 저장된 것을 쓰므로 몇 초면 된다
model = SentenceTransformer("intfloat/multilingual-e5-small")
# 이 모델이 문장 하나를 숫자 몇 개로 바꾸는지 물어본다
print("차원:", model.get_sentence_embedding_dimension())
- 모델 불러오기 7.4초
- 차원: 384
- multilingual
- 여러 나라 말
- 한국어가 된다
- e5
- 모델 계열 이름
- 사람 이름 같은 것
- small
- 작은 판
- base · large 도 있다
3. 문장 하나가 숫자 384개가 된다
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small")
# encode 가 하는 일이 전부다 — 글자를 넣으면 숫자 묶음이 나온다
v = model.encode("건조한 피부에 순한 토너")
# type(v) 는 "v 가 어떤 종류의 값인가" 를 알려준다.
# 그냥 찍으면 <class 'numpy.ndarray'> 처럼 길게 나오는데,
# 뒤에 .__name__ 을 붙이면 종류의 "이름만" 꺼내준다 -> ndarray
#
# 앞뒤로 밑줄 두 개가 붙은 이름들(__name__ · __len__ 등)은 파이썬이 미리 정해둔
# 특별한 이름이다. 우리가 지은 게 아니라 파이썬이 자동으로 채워둔 값이라고 보면 된다
print("타입:", type(v).__name__)
# shape 는 "몇 개짜리인가" 를 알려준다. (384,) = 384개가 한 줄로 들어 있다
print("모양:", v.shape)
# 앞의 다섯 개만 찍어본다. round 는 소수점을 잘라 보기 좋게 만든다
print("앞 5개:", v[:5].round(4))
- 타입: ndarray
- 모양: (384,)
- 앞 5개: [ 0.0623 -0.0149 -0.0196 -0.113 0.103 ]
4. 낱말이 안 겹쳐도 가깝다
이게 이번 편의 핵심이다.
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small")
texts = [
"건조한 피부에 순한 토너",
"속당김 잡아주는 수분 화장수", # 위와 겹치는 낱말이 하나도 없다
"지성 피부용 산뜻한 클렌징폼",
"오늘 점심 뭐 먹지",
]
# 목록을 통째로 넣으면 한 번에 다 바꿔준다. 하나씩 넣는 것보다 빠르다.
# normalize_embeddings=True 는 "모든 벡터의 길이를 1로 맞춰라" 는 뜻이다.
# 길이를 맞춰두면 두 벡터를 곱하는 것만으로 비슷한 정도가 나온다 (아래에서 씀)
E = model.encode(texts, normalize_embeddings=True)
print("모양:", E.shape) # (4, 384) = 문장 4개 x 숫자 384개
# @ 는 파이썬의 "행렬 곱하기" 기호다. 길이를 1로 맞춰둔 벡터끼리 곱하면
# 결과가 -1 ~ 1 사이 값이 되고, 1에 가까울수록 뜻이 가깝다.
# 이걸 코사인 유사도라고 부른다
# 첫 문장(E[0])을 기준으로 나머지와 하나씩 비교한다.
# range(1, 4) 는 1, 2, 3 이다 — 0번(기준 문장)은 자기 자신이라 건너뛴다.
#
# 결과는 **문장을 적은 순서대로** 찍힌다. 점수 순이 아니다
for i in range(1, len(texts)):
score = E[0] @ E[i]
print(f" {score:.4f} {texts[i]}")
- 모양: (4, 384)
- 0.8534 속당김 잡아주는 수분 화장수
- 0.8793 지성 피부용 산뜻한 클렌징폼
- 0.8415 오늘 점심 뭐 먹지
첫 줄을 보자. 겹치는 낱말이 하나도 없는데 0.85 가 나왔다. 글자를 맞춰보는 방식으로는 절대 못 하는 일이다.
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
numpy | 숫자 계산 도구 | 384개를 한 번에 다루려고 |
ndarray | numpy 의 숫자 묶음 | 목록보다 계산이 빠르다 |
.shape | 몇 개짜리인가 | (4, 384) = 4문장 × 384숫자 |
normalize_embeddings=True | 길이를 1로 맞춘다 | 맞춰두면 곱하기 하나로 비교된다 |
@ | 행렬 곱하기 기호 | 두 벡터의 닮은 정도를 낸다 |
| 코사인 유사도 | -1 ~ 1 사이의 닮은 정도 | 1에 가까울수록 가깝다 |
점수 순으로 세워본다
순위를 보려면 정렬해야 한다. 여기서부터가 진짜 이야기다.
# (점수, 문장) 을 짝지어 목록에 모은다
pairs = []
for i in range(1, len(texts)):
pairs.append((E[0] @ E[i], texts[i]))
# sorted 는 작은 것부터 정렬한다. reverse=True 를 주면 큰 것부터가 된다.
# 짝(튜플)을 정렬하면 앞의 값(점수)을 기준으로 줄을 세운다
for score, text in sorted(pairs, reverse=True):
print(f" {score:.4f} {text}")
- 0.8793 지성 피부용 산뜻한 클렌징폼
- 0.8534 속당김 잡아주는 수분 화장수
- 0.8415 오늘 점심 뭐 먹지
| 이름 | 무슨 뜻인가 |
|---|---|
(a, b) | 짝(튜플). 값 두 개를 한 덩어리로 묶는다 |
sorted(목록) | 작은 것부터 줄을 세운 새 목록을 돌려준다 (원본은 그대로) |
reverse=True | 큰 것부터로 뒤집는다 |
| 짝을 정렬하면 | 앞의 값을 기준으로 선다. 그래서 (점수, 문장) 순서로 담았다 |
5. 🔴 그런데 「오늘 점심 뭐 먹지」가 0.8415 다
위 결과를 다시 보자. 화장품과 아무 상관 없는 문장이 0.84 를 받았다. 그리고 더 나쁜 것이 있다.
사람이 보기에
「건조한 피부에 순한 토너」와
- ① 속당김 잡아주는 수분 화장수
- ② 지성 피부용 산뜻한 클렌징폼
- ③ 오늘 점심 뭐 먹지
- ①이 압도적으로 가깝다
점수가 매긴 순위
실제 결과
- ① 지성 피부용 클렌징폼 0.8793
- ② 속당김 수분 화장수 0.8534
- ③ 오늘 점심 뭐 먹지 0.8415
- 엉뚱한 것이 1위다
모델마다 쓰는 법이 따로 있다
e5 계열 모델은 만든 쪽에서 문장 앞에 표시를 붙이라고 안내한다. 찾는 쪽에는 query:, 찾히는 쪽에는 passage: 를 붙이는 식이다.
E = model.encode(["query: 건조한 피부에 순한 토너",
"passage: 속당김 잡아주는 수분 화장수",
"passage: 오늘 점심 뭐 먹지"], normalize_embeddings=True)
차이가 2.4배로 벌어졌다. 그런데 여기서 멈추면 안 된다.
이번 편에 나온 것
| 쓴 것 · 안 것 | 내용 |
|---|---|
python -m pip install | pip 앞에 붙이면 이 파이썬에 깔린다 |
SentenceTransformer(이름) | 모델을 불러온다. 처음엔 받아온다 (약 470MB) |
model.encode(문장) | 글자 → 숫자 384개 |
.shape | (384,) 하나 · (4, 384) 네 개 |
normalize_embeddings=True | 길이를 1로. 곱하기 하나로 비교된다 |
E[0] @ E[1] | 닮은 정도. 1에 가까울수록 가깝다 |
| 된 것 | 낱말이 안 겹쳐도 0.85 — 글자 맞추기로는 못 하는 일 |
| 🔴 안 되는 것 | 무관한 문장도 0.84. 건성용에 지성용이 1위로 왔다 |
| 결론 | 점수로 문턱을 못 세운다. 순위만 쓴다 |
| e5 표시 | 차이는 2.4배 벌어지는데 적중률은 그대로 |
전역에 깔린 것과 지우는 법
전역에 깔았으니 무엇이 어디에 쌓였는지 알고 있어야 한다.
| 무엇 | 어디에 | 크기 |
|---|---|---|
torch | C:\Python313\Lib\site-packages | 465MB |
transformers | 같은 곳 | 98MB |
numpy | 같은 곳 | 31MB |
sentence-transformers | 같은 곳 | 4MB |
| 모델 파일 | C:\사용자\<내이름>\.cache\huggingface | 471MB |
확인하는 법
python -m pip show sentence-transformers # 버전과 깔린 자리
python -m pip list # 지금 깔린 것 전부
모델 파일은 탐색기 주소창에 아래를 붙여넣으면 열린다.
%USERPROFILE%\.cache\huggingface\hub
models--intfloat--multilingual-e5-small 같은 폴더가 모델 하나다. 폴더 이름이 곧 모델 이름이라 어느 것이 뭔지 바로 보인다.
지우는 법
python -m pip uninstall sentence-transformers torch transformers
미션
- 01
[필수] 내 문장 다섯 개를 비교한다
mission_embed_1.py. 기준 문장 하나를 정하고 가까울 것 같은 것 둘 · 멀 것 같은 것 둘을 지어 점수를 재본다. 예상과 다른 게 하나라도 나오면 그걸 적는다. - 02
[응용] 무관한 문장의 점수를 재본다
mission_embed_2.py. 화장품과 전혀 상관없는 문장 다섯 개(날씨·스포츠 등)로 점수를 재고, 제일 높게 나온 값을 적는다. 문턱을 세울 수 있을까. - 03
[도전] 큰 모델과 비교한다
mission_embed_3.py.multilingual-e5-base로 바꿔본다(약 1GB 더 받는다). 차원 · 속도 · 순위가 어떻게 달라지나. 무엇을 얻고 무엇을 잃었는지 적는다.
[도전] 큰 모델로 바꾸면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
차원이 384에서 768로 는다. 저장할 숫자가 두 배가 되고, 비교 계산도 두 배가 된다.
그런데 순위가 극적으로 좋아지지는 않는다. 큰 모델은 대체로 조금 낫고, 그 「조금」이 우리 과제에서 값을 하는지는 재봐야 안다.
고를 때 보는 것은 셋이다 — 차원(저장·계산 비용), 속도(우리는 CPU 로 돈다), 우리 과제에서의 적중률.
다음 편에서 상품 200개를 벡터로 만드는데, 거기서 모델을 바꾸면 전부 다시 만들어야 한다. 바꾸는 비용이 생각보다 크다는 것까지 같이 봐야 한다.
import time
from sentence_transformers import SentenceTransformer
texts = ["건조한 피부에 순한 토너", "속당김 잡아주는 수분 화장수", "오늘 점심 뭐 먹지"]
for model_name in ["intfloat/multilingual-e5-small", "intfloat/multilingual-e5-base"]:
model = SentenceTransformer(model_name)
started = time.time()
E = model.encode(texts, normalize_embeddings=True)
took = time.time() - started
print(f"{model_name}")
print(f" 차원 {E.shape[1]} · {took:.2f}초")
print(f" 관련 {E[0] @ E[1]:.4f} · 무관 {E[0] @ E[2]:.4f}")e5-base 는 받는 데만 1GB 가 넘는다. 여러 명이 동시에 받으면 인터넷이 버티지 못한다.
그리고 위에서 본 것을 여기서도 확인하게 된다 — 점수 차이가 벌어져도 적중률은 안 오를 수 있다. 판단은 항상 우리 과제로 한다.
정리하면
글자를 맞춰보는 방식의 벽을 넘었다. 「건조한 피부에 순한 토너」와 「속당김 잡아주는 수분 화장수」는 겹치는 낱말이 하나도 없는데 0.85 로 가깝다고 나온다. LIKE 로는 절대 못 하는 일이다.
방법은 간단했다. 문장을 숫자 384개로 바꾸고, 그 숫자들을 곱해서 닮은 정도를 낸다. 코드로는 encode 한 줄과 @ 하나다.
그런데 바로 한계도 봤다. 「오늘 점심 뭐 먹지」가 0.8415 를 받고, 건성용을 찾는데 지성용이 1위로 왔다. 이 모델은 한국어 문장이면 대체로 0.8 언저리를 주기 때문에 값의 크기에는 정보가 거의 없다. 쓸 수 있는 건 순위뿐이다.
그리고 하나 더 — e5 표시를 붙이니 점수 차이는 2.4배로 벌어졌는데 적중률은 그대로였다. 눈에 보이는 숫자가 좋아진 것과 실제로 나아진 것은 다르다. 판단은 우리 과제로 한다.
다음 편에서 이걸 실제 데이터에 건다. 상품 200개의 설명을 벡터로 만들어 DB 에 저장하고, 고객의 구매 이력을 벡터로 만들어 가까운 상품을 찾는다. 거기서 방금 배운 한계가 어떤 모양으로 나타나는지 보게 된다.