의미 검색(Semantic Search)은 어떻게 동작할까: 임베딩과 벡터 유사도
전에 BM25 랭킹 알고리즘을 다루면서 마지막에 숙제를 하나 남겨뒀는데요. “환불”로 검색하면 정작 “결제 취소”가 적힌 안내 문서를 찾지 못한다는 키워드 검색의 약점이었습니다. 두 표현은 사람이 보기엔 같은 뜻이지만, 글자가 하나도 겹치지 않으니 단어 일치에 기대는 검색은 둘을 연결하지 못해요. 😅
이 빈틈을 메우는 게 바로 의미 검색(Semantic Search)입니다. 글자가 아니라 “의미”를 기준으로 문서를 찾는 방식이죠. 요즘 챗봇, 추천, RAG(검색 증강 생성)의 바탕에 거의 빠짐없이 깔려 있는 기술이기도 합니다. 이번 글에서는 의미 검색이 도대체 어떤 원리로 “환불”과 “결제 취소”를 이어주는지, 임베딩부터 벡터 유사도, 그리고 대규모에서의 검색 방법까지 직접 코드를 돌려보며 따라가 보겠습니다.
키워드 검색이 놓치는 것
BM25 같은 키워드 검색은 빠르고 정확하지만, 근본적으로 단어가 일치해야만 동작합니다. 그래서 어휘 불일치(vocabulary mismatch)라고 부르는 문제에 늘 취약해요. 사용자가 쓰는 단어와 문서에 적힌 단어가 다르면, 의미가 같아도 검색이 실패하는 거죠.
이런 상황은 생각보다 흔합니다. “환불”과 “결제 취소”처럼 같은 뜻을 다른 단어로 표현하는 경우가 있고, “노트북”과 “랩탑”처럼 동의어가 갈리는 경우도 있어요. “면접 때 입을 옷”이라고 검색했는데 정답 문서에는 “정장 추천”이라고만 적혀 있을 수도 있고요. 사람은 이 연결을 자연스럽게 떠올리지만, 단어만 비교하는 알고리즘에게는 완전히 남남인 문장입니다.
그래서 필요한 건 단어가 아니라 의미를 비교하는 방법입니다. 문제는 “의미”라는 게 추상적이라는 거예요. 컴퓨터가 다룰 수 있으려면 의미를 어떤 식으로든 숫자로 바꿔야 합니다. 바로 여기서 임베딩이 등장합니다.
의미를 숫자로 바꾸는 임베딩
임베딩(embedding)은 텍스트를 고차원 공간의 벡터, 즉 숫자 배열로 변환한 결과입니다. 핵심은 이 변환이 아무렇게나 이루어지지 않는다는 점이에요. 의미가 비슷한 문장은 벡터 공간에서 서로 가까운 곳에, 의미가 다른 문장은 멀리 떨어진 곳에 놓이도록 설계됩니다.
비유하자면 거대한 지도를 떠올리면 됩니다. “환불”과 “결제 취소”는 지도 위 가까운 동네에 자리 잡고, “비밀번호 변경”은 한참 떨어진 다른 지역에 놓이는 식이죠. 이 지도의 좌표가 바로 벡터고, 좌표의 축 개수가 차원입니다. 실제 임베딩은 보통 수백에서 수천 차원을 쓰는데, 차원이 많을수록 더 미묘한 의미 차이까지 담을 수 있어요.
의미가 좌표로 바뀐다는 게 잘 와닿지 않을 수도 있는데요. 초기 임베딩 연구에서 유명한 예가 하나 있습니다. “왕”의 벡터에서 “남자” 벡터를 빼고 “여자” 벡터를 더하면, 그 결과가 “여왕” 벡터와 매우 가까워진다는 거예요. 의미의 관계가 벡터의 덧셈과 뺄셈으로 표현될 만큼, 임베딩 공간이 의미를 기하학적으로 담고 있다는 뜻이죠. 이런 성질 덕분에 단어가 아니라 의미 자체를 계산의 대상으로 삼을 수 있게 됩니다.
그럼 이 지도는 누가 그릴까요? 방대한 텍스트로 학습한 임베딩 모델입니다. 모델은 “어떤 단어들이 비슷한 맥락에서 함께 등장하는가”를 학습하면서 의미 공간을 구성해요. 우리가 직접 만들 필요는 없고, OpenAI나 Cohere 같은 API, 또는 Cloudflare Workers AI로 서버리스 추론을 쓰거나 오픈소스 모델을 내려받아 텍스트를 넣으면 벡터가 나옵니다. 이 글의 예제에서도 공개된 한국어 임베딩 모델을 그대로 가져다 쓸 거예요.
코사인 유사도: 벡터의 방향 비교하기
텍스트가 벡터가 됐다면, 이제 두 벡터가 얼마나 비슷한지를 잴 차례입니다. 가장 널리 쓰이는 척도가 코사인 유사도(cosine similarity)예요. 이름 그대로 두 벡터 사이 각도의 코사인 값을 쓰는데, 방향이 비슷할수록 1에 가깝고 무관할수록 0에 가까워집니다.
cos(A, B) = (A · B) / (|A| · |B|)
분자는 두 벡터의 내적(dot product)이고, 분모는 각 벡터의 크기(길이)입니다. 크기로 나눠주기 때문에 벡터가 얼마나 긴지는 무시하고 순수하게 방향만 비교해요. 그래서 문장의 길이 같은 요소에 휘둘리지 않고 의미의 방향성만 따질 수 있습니다.
왜 하필 방향일까요? 의미 공간에서는 “어느 쪽을 가리키느냐”가 곧 “무엇에 관한 내용이냐”에 대응하기 때문입니다. 짧은 문장이든 긴 문단이든 같은 주제를 다룬다면 벡터가 같은 방향을 향하고, 주제가 다르면 다른 방향을 향해요. 거리(유클리드 거리)로 재는 방법도 있지만, 의미 검색에서는 크기보다 방향이 의미를 더 잘 담아내는 경우가 많아서 코사인 유사도가 사실상 표준처럼 쓰입니다.
여기서 자주 쓰는 요령이 하나 있어요. 임베딩을 미리 크기 1로 정규화(normalize)해두면 분모가 1이 되니까, 코사인 유사도가 그냥 내적과 같아집니다. 그러면 계산이 단순한 곱셈과 덧셈으로 끝나서 대량의 벡터를 비교할 때 훨씬 빠르죠. 실제 pgvector로 유사도 검색을 할 때도 코사인 거리를 기본으로 권장하는 임베딩 모델이 많은 이유입니다.
직접 의미 검색 해보기
말로만 들으면 추상적이니 실제로 돌려봅시다. 파이썬의 sentence-transformers 라이브러리로 한국어 임베딩 모델을 불러와, 앞에서 얘기한 “환불” 문제를 그대로 재현해볼게요.
pip install sentence-transformers
from sentence_transformers import SentenceTransformer
import numpy as np
# 한국어 문장 임베딩에 특화된 공개 모델
model = SentenceTransformer("jhgan/ko-sroberta-multitask")
docs = [
"결제를 취소하고 대금을 돌려받는 절차", # '환불'이라는 단어 없이 같은 의미
"비밀번호를 변경하는 방법 안내",
"배송 상태를 조회하는 방법",
"회원 등급별 적립금 혜택 정리",
]
query = "환불하고 싶어요"
# normalize_embeddings=True 로 크기 1 정규화 → 내적이 곧 코사인 유사도
doc_emb = model.encode(docs, normalize_embeddings=True)
q_emb = model.encode(query, normalize_embeddings=True)
sims = doc_emb @ q_emb
print(f"쿼리: {query}")
print(f"임베딩 차원: {doc_emb.shape[1]}\n")
for i in np.argsort(-sims):
print(f"{sims[i]:.4f} {docs[i]}")
쿼리: 환불하고 싶어요
임베딩 차원: 768
0.6493 결제를 취소하고 대금을 돌려받는 절차
0.2712 배송 상태를 조회하는 방법
0.2360 비밀번호를 변경하는 방법 안내
0.2032 회원 등급별 적립금 혜택 정리
결과가 꽤 인상적이에요. 1위로 올라온 “결제를 취소하고 대금을 돌려받는 절차”는 쿼리 “환불하고 싶어요”와 글자가 하나도 겹치지 않습니다. 그런데도 유사도 0.6493으로 다른 문서들(0.20~0.27)을 크게 따돌렸어요. BM25였다면 이 문서는 공유하는 단어가 없으니 0점을 받아 아예 검색되지 않았을 텐데, 의미 검색은 “환불”과 “결제 취소 + 대금 돌려받기”가 같은 뜻이라는 걸 벡터 거리로 잡아낸 겁니다. 우리가 BM25 글에서 남겨둔 숙제가 깔끔하게 풀린 셈이죠.
임베딩 차원이 768로 찍힌 것도 눈여겨볼 만해요. 단어 하나하나가 아니라 문장 전체의 의미가 768개의 숫자로 압축돼서, 그 숫자들의 방향이 의미의 닮음을 표현하고 있는 겁니다.
웹 개발자라면 이 과정을 브라우저나 Node.js에서 바로 돌리고 싶을 텐데요. Transformers.js를 쓰면 파이썬 없이 자바스크립트만으로 임베딩을 뽑을 수 있습니다. 위에서 쓴 한국어 특화 모델은 JS용으로 변환돼 있지 않아서, 여기서는 한국어를 포함한 100여 개 언어를 지원하는 다국어 모델 LaBSE를 사용할게요.
npm install @huggingface/transformers
import { pipeline } from "@huggingface/transformers";
// 한국어를 포함한 다국어 문장 임베딩 모델
const extractor = await pipeline("feature-extraction", "Xenova/LaBSE");
const docs = [
"결제를 취소하고 대금을 돌려받는 절차", // '환불'이라는 단어 없이 같은 의미
"비밀번호를 변경하는 방법 안내",
"배송 상태를 조회하는 방법",
"회원 등급별 적립금 혜택 정리",
];
const query = "환불하고 싶어요";
// normalize: true 로 크기 1 정규화 → 내적이 곧 코사인 유사도
const opts = { pooling: "mean", normalize: true };
const docEmb = await extractor(docs, opts);
const qEmb = await extractor(query, opts);
const dim = qEmb.dims.at(-1);
const q = qEmb.data;
const sims = docs.map((_, i) => {
const row = docEmb.data.slice(i * dim, (i + 1) * dim);
return row.reduce((sum, v, j) => sum + v * q[j], 0); // 내적 = 코사인 유사도
});
console.log(`쿼리: ${query}`);
console.log(`임베딩 차원: ${dim}\n`);
docs
.map((doc, i) => [sims[i], doc])
.sort((a, b) => b[0] - a[0])
.forEach(([s, doc]) => console.log(`${s.toFixed(4)} ${doc}`));
쿼리: 환불하고 싶어요
임베딩 차원: 768
0.7095 결제를 취소하고 대금을 돌려받는 절차
0.5772 배송 상태를 조회하는 방법
0.5731 회원 등급별 적립금 혜택 정리
0.5467 비밀번호를 변경하는 방법 안내
모델이 다르니 점수의 절댓값은 파이썬 결과와 다르지만, 핵심은 그대로예요. 쿼리와 글자가 하나도 겹치지 않는 “결제를 취소하고 대금을 돌려받는 절차”가 0.7095로 1위에 올라 나머지 문서들(0.54~0.58)을 뚜렷이 앞섰습니다. 어떤 언어, 어떤 모델로 구현하든 “글자가 아니라 의미로 찾는다”는 의미 검색의 본질은 똑같이 작동한다는 거죠.
검색 파이프라인 구성하기
원리를 알았으니 실제 검색 시스템에서 이게 어떻게 굴러가는지 정리해봅시다. 의미 검색은 크게 두 단계로 나뉩니다.
먼저 색인(indexing) 단계입니다. 검색 대상이 되는 모든 문서를 미리 임베딩 모델에 통과시켜 벡터로 바꿔두고, 그 벡터들을 데이터베이스에 저장합니다. 이 작업은 검색 요청과 무관하게 미리 해두는 거라, 문서가 100만 개든 1000만 개든 한 번만 처리하면 됩니다.
그다음이 질의(query) 단계예요. 사용자가 검색어를 입력하면 그 검색어도 똑같은 모델로 임베딩해서 쿼리 벡터를 만들고, 저장해둔 문서 벡터들 중에서 쿼리 벡터와 가장 가까운 것들을 찾아 상위 결과로 돌려줍니다. 앞의 예제에서 한 일이 바로 이 질의 단계를 축소판으로 보여준 거죠.
여기서 중요한 전제가 하나 있어요. 문서를 색인할 때 쓴 모델과 쿼리를 임베딩할 때 쓴 모델이 반드시 같아야 합니다. 같은 모델이라야 같은 의미 공간 위에 좌표가 찍히고, 그래야 거리 비교가 의미를 가지니까요. 모델을 교체하면 기존에 색인해둔 벡터를 전부 다시 만들어야 한다는 점도 실무에서 자주 마주치는 함정입니다.
규모의 문제와 근사 최근접 이웃
예제에서는 문서가 4개뿐이라 쿼리 벡터와 모든 문서 벡터의 유사도를 일일이 계산했습니다. 이렇게 전부 비교하는 방식을 완전 탐색(brute-force) 또는 정확한 최근접 이웃 검색이라고 하는데, 문서가 적을 때는 문제가 없어요. 하지만 문서가 수백만, 수천만 개가 되면 매 검색마다 그 많은 벡터와 일일이 거리를 재는 건 너무 느립니다.
그래서 실무에서는 근사 최근접 이웃(Approximate Nearest Neighbor, 이하 ANN) 검색을 씁니다. 이름에 “근사”가 붙은 이유는, 정확히 가장 가까운 벡터를 찾는 대신 “거의 가장 가까운” 벡터를 훨씬 빠르게 찾기 때문이에요. 약간의 정확도를 내주는 대신 속도를 수십, 수백 배 끌어올리는 거래죠. 검색에서는 1순위가 살짝 바뀌는 것보다 응답 속도가 훨씬 중요한 경우가 많아서 이 거래가 대체로 남는 장사입니다.
ANN을 구현하는 대표적인 자료구조로 HNSW(계층적 탐색 가능한 작은 세계 그래프)와 IVFFlat(역파일 인덱스) 같은 것들이 있는데, 다행히 이걸 직접 구현할 일은 거의 없습니다. PostgreSQL에 pgvector로 벡터 검색을 더하거나 Cloudflare Vectorize로 엣지에서 벡터 검색을 구현하면, 이런 인덱스가 내부에 이미 들어 있어서 벡터를 넣고 쿼리만 던지면 됩니다. 우리는 어떤 거리 함수를 쓸지, 인덱스 파라미터를 어떻게 잡을지 정도만 신경 쓰면 돼요.
의미 검색의 한계
의미 검색이 만능처럼 보이지만, 약점도 분명합니다. 역설적이게도 정확한 일치에는 오히려 약해요. 상품 코드 “SKU-8842-X”, 사람 이름, 특정 에러 메시지처럼 글자 그대로 정확히 찾아야 하는 경우, 의미를 뭉뚱그리는 임베딩은 비슷한 코드와 헷갈리거나 엉뚱한 결과를 내놓을 수 있습니다. 이런 건 차라리 키워드 검색이 훨씬 잘하죠.
긴 문서를 다룰 때 생기는 청킹(chunking) 문제도 있어요. 임베딩 모델은 한 번에 처리할 수 있는 길이에 한계가 있어서, 긴 문서는 적당한 크기로 잘라서 각 조각을 따로 임베딩합니다. 그런데 어디서 자르느냐에 따라 검색 품질이 크게 달라지고, 자칫 문맥이 중간에 끊기면 의미가 왜곡되기도 합니다.
이 밖에도 임베딩을 생성하고 저장하는 비용과 지연이 키워드 검색보다 크고, 일반적인 모델은 법률이나 의료 같은 특수 도메인의 미묘한 용어 차이를 제대로 못 잡을 때가 있습니다. 결국 의미 검색도 만병통치약이 아니라, 잘하는 일과 못하는 일이 뚜렷한 도구인 셈이에요.
하이브리드 검색으로 가는 길
여기까지 오면 자연스러운 결론에 도달합니다. 키워드 검색과 의미 검색은 서로 잘하는 영역이 정반대라는 거예요. BM25 같은 키워드 검색은 정확한 단어 일치에 강하고, 의미 검색은 표현이 달라도 의미로 연결하는 데 강합니다. 그렇다면 둘을 함께 쓰면 어떨까요?
이렇게 두 방식의 결과를 결합하는 걸 하이브리드 검색(hybrid search)이라고 부릅니다. 키워드 검색의 정확성과 의미 검색의 유연함을 모두 챙기려는 시도죠. 다만 서로 점수 체계가 전혀 다른 두 검색 결과를 어떻게 하나로 합칠 것인가, 라는 새로운 숙제가 생깁니다. 이 부분은 따로 한 편을 할애해서 RRF로 하이브리드 검색 점수를 합치는 방법에서 이어서 살펴봅니다.
마치며
의미 검색은 “텍스트를 의미가 담긴 벡터로 바꾸고, 벡터 사이의 거리로 관련도를 잰다”는 한 문장으로 요약됩니다. 임베딩이 의미를 좌표로 만들고, 코사인 유사도가 그 좌표의 닮음을 재고, ANN이 그걸 대규모에서 빠르게 찾아주는 흐름이었어요. 직접 돌려본 예제에서 단어가 안 겹치는 문장을 찾아내는 걸 보면, 키워드 검색만으로는 닿지 못하던 영역이 확실히 열리는 걸 느낄 수 있습니다.
다음 단계로는 키워드 검색과 의미 검색을 결합하는 하이브리드 검색과 RRF를 살펴보시길 권합니다. 그리고 의미 검색을 실제 서비스에 붙이고 싶다면, 임베딩을 어디에 저장하고 어떻게 인덱싱할지부터 정해야 하니 벡터 데이터베이스 관련 글을 함께 참고하시면 좋습니다.
임베딩과 문장 유사도를 더 깊이 파보고 싶다면 예제에서 쓴 sentence-transformers 공식 문서를 읽어보시길 추천합니다.
This work is licensed under
CC BY 4.0