학습 자료

문장을 숫자로 바꾼다 — 낱말이 안 겹쳐도 찾으려면


Article

앞 편에서 데이터를 꺼내는 법을 배웠다. 그런데 꺼낼 수 있는 것과 없는 것이 갈린다.

SQL 로 되는 것과 안 되는 것

된다

조건이 딱 떨어진다

  • price <= 30000
  • category = '토너'
  • 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. 모델을 받는다

도구를 깔았다고 모델이 온 것은 아니다. 모델은 처음 쓸 때 자동으로 받아온다.

04_embed_first.py
# 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개가 된다

05_encode.py
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. 낱말이 안 겹쳐도 가깝다

이게 이번 편의 핵심이다.

06_compare.py
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개를 한 번에 다루려고
ndarraynumpy 의 숫자 묶음목록보다 계산이 빠르다
.shape몇 개짜리인가(4, 384) = 4문장 × 384숫자
normalize_embeddings=True길이를 1로 맞춘다맞춰두면 곱하기 하나로 비교된다
@행렬 곱하기 기호두 벡터의 닮은 정도를 낸다
코사인 유사도-1 ~ 1 사이의 닮은 정도1에 가까울수록 가깝다

점수 순으로 세워본다

순위를 보려면 정렬해야 한다. 여기서부터가 진짜 이야기다.

06_compare.py — 이어서
# (점수, 문장) 을 짝지어 목록에 모은다
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위다
건성용을 찾는데 지성용이 1위로 왔다. 둘 다 「피부·제형」 이야기라 모델 눈에는 비슷해 보인 것이다.

모델마다 쓰는 법이 따로 있다

e5 계열 모델은 만든 쪽에서 문장 앞에 표시를 붙이라고 안내한다. 찾는 쪽에는 query:, 찾히는 쪽에는 passage: 를 붙이는 식이다.

E = model.encode(["query: 건조한 피부에 순한 토너",
                  "passage: 속당김 잡아주는 수분 화장수",
                  "passage: 오늘 점심 뭐 먹지"], normalize_embeddings=True)
관련 문장과 무관 문장의 점수 차이
표시 없이0.012점
query · passage 붙이면0.029점

차이가 2.4배로 벌어졌다. 그런데 여기서 멈추면 안 된다.

이번 편에 나온 것

정리
쓴 것 · 안 것내용
python -m pip installpip 앞에 붙이면 이 파이썬에 깔린다
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배 벌어지는데 적중률은 그대로

전역에 깔린 것과 지우는 법

전역에 깔았으니 무엇이 어디에 쌓였는지 알고 있어야 한다.

이번 편에서 늘어난 것
무엇어디에크기
torchC:\Python313\Lib\site-packages465MB
transformers같은 곳98MB
numpy같은 곳31MB
sentence-transformers같은 곳4MB
모델 파일C:\사용자\<내이름>\.cache\huggingface471MB
패키지 약 600MB + 모델 471MB. 합쳐서 1GB 남짓이다.

확인하는 법

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

미션

미션
  1. 01

    [필수] 내 문장 다섯 개를 비교한다

    mission_embed_1.py. 기준 문장 하나를 정하고 가까울 것 같은 것 둘 · 멀 것 같은 것 둘을 지어 점수를 재본다. 예상과 다른 게 하나라도 나오면 그걸 적는다.

  2. 02

    [응용] 무관한 문장의 점수를 재본다

    mission_embed_2.py. 화장품과 전혀 상관없는 문장 다섯 개(날씨·스포츠 등)로 점수를 재고, 제일 높게 나온 값을 적는다. 문턱을 세울 수 있을까.

  3. 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 에 저장하고, 고객의 구매 이력을 벡터로 만들어 가까운 상품을 찾는다. 거기서 방금 배운 한계가 어떤 모양으로 나타나는지 보게 된다.

Share
  • 파이썬
  • AI
  • 임베딩
  • 검색
  • 벡터