LLM 앱 개발 실전 #7 임베딩과 벡터 검색
6편에서 Claude를 외부 기능과 연결했습니다. 그런데 흔한 요구가 하나 있습니다. “우리 회사 문서를 보고 답해줘.” Claude는 우리 문서를 모르므로, 질문과 관련된 문서를 먼저 찾아 함께 건네줘야 합니다. 이 “관련 문서 찾기"의 핵심 기술이 임베딩과 벡터 검색입니다. 이번 글에서 그 토대를 만들고, 다음 글에서 RAG로 완성합니다.
임베딩이란 #
임베딩은 텍스트를 숫자의 목록, 즉 벡터로 바꾸는 것입니다. 단순한 변환이 아니라 의미가 비슷한 텍스트는 비슷한 벡터가 되도록 만들어진 변환입니다. 예를 들어 “강아지"와 “반려견"은 가까운 벡터가 되고, “강아지"와 “주식 시장"은 멀리 떨어진 벡터가 됩니다.
이 성질 덕분에 벡터 사이의 거리를 재면 의미의 유사도를 잴 수 있습니다. 키워드가 정확히 겹치지 않아도, “환불 방법"으로 검색해 “결제 취소 절차"를 다룬 문서를 찾아낼 수 있습니다. 단어가 아니라 의미로 찾기 때문입니다.
sentence-transformers를 씁니다. 실제 서비스에서는 품질이 높은 호스팅 임베딩 API(예: Voyage AI)를 쓰기도 합니다. 어느 쪽이든 “텍스트를 넣으면 벡터가 나온다"는 사용법은 같습니다.텍스트를 벡터로 바꾸기 #
sentence-transformers로 문장 몇 개를 벡터로 바꿔 보겠습니다.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
texts = [
"강아지를 산책시키는 방법",
"반려견 운동의 중요성",
"오늘의 주식 시장 동향",
]
vectors = model.encode(texts)
print(vectors.shape) # (3, 384) — 문장 3개, 각 384차원 벡터각 문장이 384개의 숫자로 된 벡터가 됐습니다. 차원 수(여기서는 384)는 모델마다 다르지만, 같은 모델로 만든 벡터끼리는 같은 차원이라 서로 거리를 잴 수 있습니다.
벡터로 유사도 재기 #
두 벡터가 얼마나 비슷한지는 보통 코사인 유사도로 잽니다. 값이 1에 가까울수록 의미가 비슷하고, 0에 가까울수록 무관합니다.
import numpy as np
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
dog_walk, dog_exercise, stocks = vectors
print(cosine_similarity(dog_walk, dog_exercise)) # 높음 (둘 다 강아지)
print(cosine_similarity(dog_walk, stocks)) # 낮음 (무관)“강아지 산책"과 “반려견 운동"은 단어가 거의 겹치지 않는데도 유사도가 높게 나옵니다. 의미가 가깝기 때문입니다. 반면 “강아지 산책"과 “주식 동향"은 낮습니다. 이것이 키워드 검색과 다른 점입니다.
벡터 검색 만들기 #
문서 여러 개를 미리 벡터로 바꿔 두면, 질문이 들어왔을 때 질문도 벡터로 바꿔 가장 비슷한 문서를 찾을 수 있습니다. 이것이 벡터 검색입니다.
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
documents = [
"환불은 구매 후 7일 이내에 마이페이지에서 신청할 수 있습니다.",
"배송은 주문 후 보통 2~3일이 걸립니다.",
"회원 등급은 누적 구매액에 따라 자동으로 올라갑니다.",
]
doc_vectors = model.encode(documents)
def search(query: str, top_k: int = 1):
q = model.encode([query])[0]
scores = doc_vectors @ q # 정규화돼 있으면 내적이 곧 코사인 유사도
ranked = np.argsort(scores)[::-1][:top_k]
return [(documents[i], float(scores[i])) for i in ranked]
print(search("돈 돌려받으려면 어떻게 해요?"))
# 환불 문서가 가장 비슷한 문서로 나온다“돈 돌려받으려면"이라는 질문에 “환불"이라는 단어가 없는데도, 의미가 가장 가까운 환불 문서를 찾아냅니다. 이것이 LLM 앱에서 관련 문서를 찾는 기본 원리입니다.
벡터 데이터베이스 #
위 예제는 문서가 세 개라 그냥 전부 비교했습니다. 그런데 문서가 수만, 수십만 개가 되면 매번 전부 비교하기 부담스럽습니다. 이때 벡터를 저장하고 빠르게 검색해 주는 벡터 데이터베이스를 씁니다. Qdrant, Chroma, 그리고 PostgreSQL에 벡터 기능을 더한 pgvector 등이 많이 쓰입니다.
벡터 데이터베이스를 써도 원리는 같습니다. 문서를 임베딩해 저장해 두고, 질문을 임베딩해 가장 가까운 것을 찾습니다. 달라지는 건 “직접 전부 비교한다"가 “데이터베이스가 빠르게 찾아준다"로 바뀌는 부분뿐입니다.
임베딩 모델 고르기 #
임베딩 모델은 여러 가지가 있고, 고를 때 보는 기준이 몇 가지 있습니다.
- 차원 수 — 벡터의 길이입니다. 차원이 높으면 더 미세한 의미 차이를 담지만, 저장 공간과 계산 비용이 늘어납니다. 위에서 쓴
all-MiniLM-L6-v2는 384차원으로 가볍고 빠른 편입니다. - 언어 — 한국어 문서를 다룬다면 한국어를 잘 다루는 모델이나 다국어 모델을 골라야 합니다. 영어 위주로 학습된 모델은 한국어 유사도가 부정확할 수 있습니다.
- 로컬과 호스팅 — 로컬 모델은 키 없이 무료로 돌지만 품질과 속도에 한계가 있습니다. 호스팅 API는 비용이 들지만 품질이 높고 긴 문서에 강합니다.
핵심은 한 번 고른 모델을 일관되게 쓰는 것입니다. 아래 함정에서 보듯 문서와 질문을 같은 모델로 임베딩해야 비교가 됩니다. 그래서 모델을 바꾸면 저장해 둔 문서 벡터도 전부 다시 만들어야 합니다. 처음에는 가벼운 모델로 시작하고, 품질이 부족하면 더 좋은 모델로 옮기되, 옮길 때 전체 재임베딩이 따른다는 점을 염두에 둡니다.
흔히 걸려 넘어지는 곳 #
- 검색 모델과 저장 모델이 다르다 — 문서를 임베딩한 모델과 질문을 임베딩하는 모델이 다르면 벡터를 비교할 수 없습니다. 같은 모델로 일관되게 만들어야 합니다.
- 정규화 가정을 헷갈린다 — 위 예제는 모델이 정규화된 벡터를 준다는 전제로 내적을 유사도로 썼습니다. 그렇지 않은 모델이라면 코사인 유사도 공식을 그대로 써야 합니다.
- 임베딩을 매번 다시 만든다 — 문서 임베딩은 한 번 만들어 저장해 두고 재사용합니다. 검색할 때마다 전체 문서를 다시 임베딩하면 느리고 비쌉니다.
마무리 #
이번 글에서는 의미로 문서를 찾는 임베딩과 벡터 검색을 다뤘습니다.
- 임베딩은 텍스트를 벡터로 바꾸되, 의미가 비슷하면 벡터도 가깝도록 만듭니다.
- 벡터 사이의 코사인 유사도로 의미의 유사도를 잽니다.
- 문서를 미리 임베딩해 두고 질문과 가장 가까운 것을 찾는 것이 벡터 검색이고, 규모가 커지면 벡터 데이터베이스를 씁니다.
이제 질문에 관련된 문서를 찾을 수 있습니다. 다음 글인 “LLM 앱 개발 실전 #8 RAG 파이프라인 구축"에서는 이렇게 찾은 문서를 Claude에게 건네, 우리 문서에 근거해 답하게 하는 RAG를 완성합니다.