포스트

RAG 기초 (3) - Embeddings: 의미를 벡터로 바꾸기

문장을 벡터로 바꿔 코사인 유사도를 직접 계산해 보고 키워드 검색과의 차이, 임베딩 모델 선택 기준과 재색인이 필요한 조건을 다룹니다.

RAG 기초 (3) - Embeddings: 의미를 벡터로 바꾸기

RAG 기초 시리즈의 3편입니다. 전체 목차는 0편에 있습니다.

질문과 chunk가 얼마나 가까운가

2편에서 chunk 7개를 만들었습니다. 질문이 들어오면 이 중 관련된 것을 골라야 하는데, 고르려면 “관련됨”을 숫자로 잴 수 있어야 합니다.

embedding은 텍스트를 수백 차원의 숫자 벡터로 바꾸는 모델입니다. 의미가 비슷한 문장은 벡터 공간에서 가까운 자리에 놓입니다.

1
2
3
4
5
6
7
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("jhgan/ko-sroberta-multitask")

vec = model.encode("제주도 배송비가 얼마인가요?")
print(vec.shape)      # (768,)
print(vec[:5])        # [ 0.213 -0.087  0.412  0.055 -0.190]

768개의 숫자가 나왔습니다. 이 숫자 각각에는 사람이 해석할 의미가 없습니다. 의미는 벡터 사이의 거리에만 있습니다.

유사도를 직접 계산하기

두 벡터가 얼마나 가까운지는 코사인 유사도로 잽니다. 두 벡터가 이루는 각도가 작을수록 1에 가깝습니다. 벡터를 길이 1로 정규화해 두면 코사인 유사도가 내적과 같아지므로 계산이 단순해집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import numpy as np

sentences = [
    "제주 및 도서산간 지역은 3,000원이 추가됩니다.",
    "주문 금액이 30,000원 이상이면 배송비가 무료입니다.",
    "적립률은 실버 1%, 골드 2%, 플래티넘 3%입니다.",
    "송장번호는 출고 다음 날 오전부터 조회할 수 있습니다.",
]
query = "도서산간 추가 요금이 있나요?"

emb = model.encode(sentences, normalize_embeddings=True)   # (4, 768)
qvec = model.encode(query, normalize_embeddings=True)      # (768,)

scores = emb @ qvec        # 정규화했으므로 내적이 곧 코사인 유사도
for s, score in sorted(zip(sentences, scores), key=lambda x: -x[1]):
    print(f"{score:.3f}  {s}")

# 0.812  제주 및 도서산간 지역은 3,000원이 추가됩니다.
# 0.478  주문 금액이 30,000원 이상이면 배송비가 무료입니다.
# 0.301  송장번호는 출고 다음 날 오전부터 조회할 수 있습니다.
# 0.194  적립률은 실버 1%, 골드 2%, 플래티넘 3%입니다.

점수의 절대값에는 의미가 없습니다. 모델마다 분포가 달라서 0.8이 높은 편인 모델도 있고 0.5가 높은 편인 모델도 있습니다. 쓸 수 있는 것은 순위뿐입니다. “0.7 이상이면 관련 있음” 같은 절대 기준을 코드에 박으면 모델을 바꿀 때 전부 무너집니다.

키워드 검색과 무엇이 다른가

같은 질문을 단어 일치로 찾아보면 차이가 드러납니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
query2 = "돈을 돌려받으려면 어떻게 하나요?"

# 키워드 검색: 질의어가 문장에 들어 있는지
for s in ["상품 수령일로부터 7일 이내에 반품을 신청할 수 있습니다.",
          "적립률은 실버 1%, 골드 2%, 플래티넘 3%입니다."]:
    hit = any(word in s for word in query2.split())
    print(hit, s)
# False 상품 수령일로부터 7일 이내에 반품을 신청할 수 있습니다.
# False 적립률은 실버 1%, 골드 2%, 플래티넘 3%입니다.

# embedding 검색
emb2 = model.encode(["상품 수령일로부터 7일 이내에 반품을 신청할 수 있습니다.",
                     "적립률은 실버 1%, 골드 2%, 플래티넘 3%입니다."],
                    normalize_embeddings=True)
print(emb2 @ model.encode(query2, normalize_embeddings=True))
# [0.593 0.121]

“돈을 돌려받다”와 “반품을 신청하다”는 겹치는 단어가 하나도 없습니다. 키워드 검색은 아무것도 찾지 못하고, embedding 검색은 반품 문장을 찾습니다. 사용자가 정책 문서의 용어를 모르는 상태로 질문한다는 점을 생각하면 이 차이가 RAG에서 embedding을 쓰는 이유입니다.

반대 방향도 있습니다. 주문번호 20260721-3312나 상품 코드처럼 정확히 일치해야 하는 문자열은 키워드 검색이 압도적으로 낫습니다. embedding은 숫자 나열의 미세한 차이를 잘 구분하지 못합니다. 두 방식을 함께 쓰는 방법이 6편에 있습니다.

모델을 고르는 기준

Claude API에는 embedding 엔드포인트가 없으므로 별도 모델을 골라야 합니다. 기준은 네 가지입니다.

기준확인할 것
언어한국어 문서라면 한국어나 다국어로 학습된 모델을 씁니다. 영어 전용 모델은 한국어에서 유사도 순위가 무너집니다
차원768이나 1024가 일반적입니다. 크면 표현력이 좋고 저장 용량과 검색 시간이 늡니다
최대 입력 길이모델이 받는 토큰 수를 넘는 chunk는 뒷부분이 잘립니다. chunk 크기를 여기에 맞춥니다
실행 위치로컬 모델은 문서가 외부로 나가지 않고 호출 비용이 없습니다. API형 모델은 설치가 없고 품질이 대체로 높습니다

이 시리즈는 한국어 문장 임베딩 모델인 jhgan/ko-sroberta-multitask를 씁니다. 다국어 문서를 함께 다뤄야 한다면 BAAI/bge-m3 같은 다국어 모델이 대안입니다. 어느 쪽이 나은지는 7편의 hit rate로 비교해서 정하면 됩니다.

chunk 전체를 벡터로

이제 2편의 chunk에 적용합니다.

1
2
3
4
5
6
7
from chunking import build_chunks   # 2편에서 만든 함수

chunks = build_chunks()
texts = [c["text"] for c in chunks]
vectors = model.encode(texts, normalize_embeddings=True, batch_size=16)

print(vectors.shape)   # (7, 768)

batch_size로 묶어 넘기면 한 건씩 호출하는 것보다 빠릅니다. chunk가 수만 개면 이 단계가 색인 시간의 대부분을 차지합니다.

검색을 numpy로 짜면 이렇게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
def search(query: str, k: int = 3):
    qvec = model.encode(query, normalize_embeddings=True)
    scores = vectors @ qvec
    top = np.argsort(-scores)[:k]
    return [(chunks[i]["source"], float(scores[i]), chunks[i]["text"][:40]) for i in top]

for source, score, preview in search("골드 등급이면 배송비가 무료인가요?"):
    print(f"{score:.3f}  {source}  {preview}...")
# 0.734  membership.md  ## 등급별 혜택 적립률은 실버 1%, 골드 2%...
# 0.612  shipping.md    # 배송 정책 ## 배송비 기본 배송비는 3,000원...
# 0.401  refund.md      ## 단순 변심 상품 수령일로부터 7일 이내에...

RAG의 검색이 실제로 이 다섯 줄입니다. 벡터를 곱하고 큰 순서로 정렬하는 것이 전부입니다. 그런데도 vector store가 필요한 이유는 다음 편에서 확인합니다.

함정

색인과 질의의 모델이 같아야 합니다. chunk를 A 모델로 임베딩해 놓고 질문을 B 모델로 임베딩하면 두 벡터는 서로 다른 공간에 있습니다. 오류는 나지 않고 검색 결과만 무의미해집니다. 모델 이름을 코드 한 곳에 상수로 두고 양쪽에서 참조합니다.

모델을 바꾸면 전부 다시 만들어야 합니다. embedding 모델 교체는 chunk 전체의 재색인을 의미합니다. chunk 크기를 바꿀 때도 마찬가지입니다.

정규화 여부를 섞으면 안 됩니다. normalize_embeddings=True로 만든 벡터와 아닌 벡터를 함께 쓰면 점수 스케일이 달라집니다. 색인과 질의 양쪽에서 동일하게 설정합니다.

다음 글: RAG 기초 (4) - Vector Store: 저장하고 찾아오기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.