이번 시간에는 검색엔진이 쓰는 두 가지 벡터, dense 벡터와 sparse 벡터가 무엇인지, 그리고 둘의 검색 결과를 RRF로 합치는 하이브리드 검색이 언제 필요한지를 개념 위주로 배워가는 시간입니다.
요즘 검색엔진을 만든다고 하면 먼저 임베딩 모델로 Semantic Search를 붙여요. 뜻이 비슷한 문장을 찾아 주니 편하죠. 그런데 사용자가 상품 상세에서 본 모델번호를 그대로 쳤는데 엉뚱한 상품이 1위로 나온다면 어떨까요?
이 글은 그 문제가 왜 생기는지부터 시작해서, sparse 벡터가 무엇을 메워 주는지, 두 결과를 어떻게 합치는지 차례로 설명할게요. 숫자가 필요한 부분은 제가 상품 검색엔진을 만들며 실측한 결과를 기반으로 알려드릴게요.
검색엔진이 문서를 찾는 두 가지 방법
검색어에 맞는 문서를 고르는 방법은 크게 두 가지예요.
- 키워드 검색: 검색어와 문서를 단어로 자르고, 겹치는 단어가 많을수록 높은 점수를 줘요. 오래전부터 검색엔진의 기본이었던 방식입니다.
- Semantic Search(의미 검색): 검색어와 문서를 임베딩 모델로 숫자 목록(벡터)으로 바꾸고, 두 벡터가 가까울수록 높은 점수를 줘요. 단어가 겹치지 않아도 뜻이 비슷하면 찾습니다.
두 방법 모두 결국 "문서를 벡터로 바꾸고, 검색어 벡터와 비교한다"는 점은 같아요. 다른 건 벡터의 모양입니다. Semantic Search가 쓰는 벡터를 dense 벡터, 키워드 검색이 쓰는 벡터를 sparse 벡터라고 불러요.
dense 벡터: 뜻을 담은 숫자 384개
dense 벡터는 숫자가 빈칸 없이 모두 채워진 벡터예요. 이 글의 실측에 쓴 multilingual-e5-small 모델은 어떤 문장을 넣어도 숫자 384개를 돌려줍니다.
숫자 하나하나에 사람이 읽을 수 있는 뜻은 없어요. 384개가 함께 문장 전체의 뜻을 담습니다.
그래서 "검은 니트"로 검색해도 "블랙"이라고 적힌 니트를 찾아요. "검은"과 "블랙"은 글자가 다르지만 뜻이 같으니까요.
이게 Semantic Search의 강점이에요. 사용자는 상품 이름을 정확히 모르고, 같은 물건도 사람마다 다르게 부르거든요.
그런데 예를 들어 사용자가 모델번호 PF-00410A를 검색한다고 해볼게요. 이 번호의 상품은 1만 개 중 딱 하나예요. dense 벡터로 검색하면 어떻게 될까요?
제 실측에서 나온 상위 5개는 이랬어요(검색 결과 출력 그대로).
1. 0.859 플레인폼 PF-00910A 핑크 우븐 롱 블라우스 | 핑크 블라우스 | 48,000원
2. 0.856 플레인폼 PF-04105B 블랙 저지 후드티 | 블랙 후드티 | 47,000원
3. 0.851 플레인폼 PF-01810E 블랙 저지 스키니 래깅스 | 블랙 래깅스 | 46,000원
4. 0.847 플레인폼 PF-02810A 그린 저지 스트리트 탑 | 그린 탑 | 99,000원
5. 0.846 플레인폼 PF-04285A 오렌지 저지 오버사이즈 롱 후드티 | 오렌지 후드티 | 71,000원
1위는 숫자 하나만 다른 PF-00910A였고, 정답인 PF-00410A 점퍼는 다섯 안에 없었어요. 왜 그럴까요?
dense 벡터는 문장의 "뜻"을 담는데, 모델번호에는 뜻이 없어요. 모델 입장에서 PF-00410A와 PF-00910A는 "PF로 시작하는 비슷한 생김새의 코드"라서 벡터도 아주 가깝게 나옵니다. 점수도 0.859, 0.856처럼 다 비슷하게 붙어 있죠.
사용자는 정확히 그 번호를 원하는데, 결과에는 생김새만 비슷한 번호가 섞이는 거예요. 모델번호, 오류 코드, 설정 이름처럼 글자 그대로 맞아야 하는 검색어가 이런 경우입니다.
sparse 벡터: 대부분이 0인 단어 횟수 벡터
이런 상황을 대비하는 방법이 sparse 벡터예요. sparse 벡터는 단어마다 번호를 하나씩 붙이고, 문서에 들어 있는 단어의 번호 자리에만 값을 넣어요. 값은 그 단어가 나온 횟수입니다.
예를 들어서 문서 텍스트가 "니트 가디건. 색상 화이트. 니트"라고 해볼게요.
- "니트"의 번호 자리: 2
- "가디건", "색상", "화이트"의 번호 자리: 1
- 나머지 모든 번호 자리: 0
세상의 단어 수만큼 자리가 있는데 값이 있는 건 네 자리뿐이에요. 대부분이 비어 있어서 sparse, 듬성듬성한 벡터라고 부릅니다. 그래서 저장할 때도 0이 아닌 자리의 번호와 값만 적어요.
{"indices": [2043963134, 2255904057, 2629441619, 3899753206], "values": [1.0, 2.0, 1.0, 1.0]}
번호는 단어를 crc32라는 해시 함수로 숫자로 바꾼 값이에요. 2255904057이 "니트"의 번호라서 값이 2.0입니다. 단어 사전의 순번을 번호로 쓰기도 하는데, 어느 쪽이든 같은 단어가 문서를 저장할 때와 검색할 때 늘 같은 번호를 받기만 하면 돼요.
검색어도 같은 방식으로 sparse 벡터로 바꾸고, 두 벡터에서 같은 번호 자리끼리 곱해 더한 값을 점수로 써요. 겹치는 단어가 없으면 점수는 0입니다.
그래서 앞의 문제가 풀려요. PF-00410A로 검색하면 PF-00410A라는 단어가 든 문서만 점수를 받고, PF-00910A가 든 문서는 한 글자 차이여도 다른 단어라서 점수를 받지 못해요.
흔한 단어는 점수를 덜 받게: IDF
그런데 겹치는 단어로만 점수를 내면 문제가 하나 더 있어요. "점퍼 PF-00410A"를 검색했다고 해볼게요.
"점퍼"는 실측에 쓴 상품 1만 개 중 497개에 나오는 단어예요. 모델번호는 한 상품에만 나오고요. 그런데 단순히 겹친 횟수로 점수를 내면, "점퍼"가 겹친 497개 상품과 모델번호가 겹친 한 상품이 비슷한 점수를 받아요.
이걸 막는 방법이 IDF(Inverse Document Frequency)예요. 많은 문서에 나오는 단어일수록 가중치를 낮추고, 드문 단어일수록 높이는 방법입니다. IDF를 켜면 "점퍼"는 작은 점수만 보태고 PF-00410A는 큰 점수를 보태서, 그 모델번호가 든 상품이 위로 올라와요.
키워드 검색의 대표 공식인 BM25도 이 IDF에 두 가지를 더한 거예요.
- 같은 단어가 여러 번 나와도 점수가 끝없이 커지지 않게 눌러 줘요(k1 값, Elasticsearch 기본값 1.2).
- 긴 문서가 단어를 많이 가졌다는 이유만으로 유리해지지 않게 평균보다 긴 문서의 점수를 깎아 줘요(b 값, Elasticsearch 기본값 0.75).
Qdrant 같은 벡터 검색엔진은 sparse 벡터 설정에 IDF 옵션(modifier: idf)을 둬요. 켜 두면 검색할 때 저장된 전체 문서의 통계로 ln((N - n + 0.5) / (n + 0.5) + 1)을 계산해 검색어 쪽 값에 곱해 줍니다. N은 전체 문서 수, n은 그 단어가 든 문서 수예요.
다만 이 IDF 옵션이 해 주는 건 IDF뿐이에요. k1과 b 계산은 sparse 벡터를 만드는 쪽에서 직접 해야 해요. 이 글의 실측은 단어 횟수에 Qdrant의 IDF만 곱한 방식이라 BM25와 완전히 같지는 않아요.
한국어는 단어를 자르는 방법이 먼저예요
sparse 벡터를 만들려면 먼저 텍스트를 단어로 잘라야 해요. 이 일을 하는 함수를 토크나이저라고 합니다.
영어는 공백으로 잘라도 대체로 맞는데, 한국어는 단어 뒤에 조사가 붙어요. 공백으로만 자르면 "니트를"과 "니트"가 서로 다른 단어가 되죠. 그래서 한국어 검색엔진에는 문장을 뜻의 최소 단위(형태소)로 나누는 형태소 분석기를 흔히 씁니다.
그런데 형태소 분석기가 늘 나은 건 아니에요. 같은 문장을 두 방식으로 잘라 봤어요.
['루프나인', 'ln-00042g', '검은색', '니트웨어']
['루프나', 'ln', '00042', 'g', '검', '색', '니트', '웨어']
첫 줄은 공백과 기호로 자르되 하이픈으로 이어진 모델번호는 한 단어로 남긴 결과고, 둘째 줄은 한국어 형태소 분석기 Kiwi로 명사·외국어·숫자 같은 품사만 남긴 결과예요.
Kiwi는 "니트웨어"를 "니트"와 "웨어"로 잘 나눴지만, 사전에 없는 브랜드 "루프나인"은 "루프나"로 잘렸고 모델번호는 세 조각이 됐어요. 사전에 없는 브랜드가 많은 상품 검색에서는 이게 점수를 깎습니다.
모델이 만드는 sparse 벡터도 있어요
지금까지 설명한 sparse 벡터는 단어 횟수를 세는 방식이에요. 이와 달리 학습한 모델이 단어마다 가중치를 매겨 주는 sparse 벡터도 있어요.
- SPLADE(2021): 언어 모델이 단어 사전 전체(약 3만 개)의 단어마다 가중치를 매겨요. 문서에 없던 관련 단어에도 값을 줘서, 글자가 안 겹치는 문제를 일부 덜어 줍니다.
- bge-m3의 sparse 출력(2024): dense 벡터를 만드는 같은 모델이 입력에 든 토큰(모델이 자른 단어 조각)마다 중요도를 매겨요. 문서에 없는 단어를 더하지는 않고, 겹친 토큰의 가중치를 곱해 더한 값을 점수로 써요.
왜 둘 다 쓸까: 서로 못 찾는 검색어가 달라요
여기까지 보면 sparse 벡터가 dense 벡터를 대신할 수 있을 것 같지만, 그렇지 않아요. sparse 벡터는 글자가 겹쳐야만 점수가 나기 때문이에요.
"검은 니트"를 검색했는데 상품 이름에 "블랙 니트"라고 적혀 있다면, sparse 벡터는 "니트"만 겹친 것으로 보고 "검은"과 "블랙"이 같은 뜻이라는 걸 몰라요. 영문 브랜드로 검색했는데 상품에는 한글 브랜드만 적혀 있어도 마찬가지고요.
정리하면 두 벡터는 잘 찾는 검색어가 반대예요.
- dense 벡터(Semantic Search): 뜻으로 찾는 검색어, 다른 말로 바꿔 쓴 검색어에 강하고, 모델번호처럼 뜻이 없는 문자열에 약해요.
- sparse 벡터(키워드 검색): 글자가 그대로 겹치는 검색어, 드문 단어가 든 검색어에 강하고, 동의어나 다른 언어 표기에 약해요.
실제 검색엔진에는 두 종류의 검색어가 섞여 들어와요. 그래서 하나를 버리지 않고, 두 방식으로 각각 검색한 다음 결과를 합치는 하이브리드 검색을 씁니다.
두 결과를 합치는 방법: RRF
그럼 두 목록을 어떻게 하나로 합칠까요? 가장 먼저 떠오르는 건 두 점수를 더하는 건데, 문제가 있어요.
dense 쪽 점수는 두 벡터의 방향이 얼마나 같은지 재는 코사인 유사도라서 1을 넘지 않고, 앞의 출력처럼 0.84~0.86 사이에 몰려 있기도 해요. sparse 쪽 점수는 겹친 단어가 많고 드물수록 몇 배로 커질 수 있고요. 범위가 다른 두 점수를 그대로 더하면 범위가 큰 sparse 점수가 결과를 거의 다 정하게 됩니다.
이런 상황을 대비하는 방법이 RRF(Reciprocal Rank Fusion)예요. RRF는 점수가 아니라 순위로 합칩니다. 각 목록에서 위에 있을수록 큰 값을 주고, 목록마다 받은 값을 더해요.
RRF 점수(d) = Σ 1 / (k + 순위)
- 순위: 그 목록에서 문서 d가 몇 번째인지예요. 한쪽 목록에 없는 문서는 그 목록 몫이 0이에요.
- k: 상수예요. 순위가 내려갈 때 받는 값이 너무 가파르게 줄면 한 목록의 1위가 결과를 혼자 정하게 되는데, k가 그 차이를 완만하게 눌러 줘요.
- RRF를 처음 제안한 논문(Cormack 외, SIGIR 2009)은 순위를 1부터 세고 k=60을 썼어요. Elasticsearch의 RRF도 기본값이 60이에요.
- Qdrant는 순위를 0부터 세고 k 기본값이 2예요(1.16부터 바꿀 수 있어요). 그래서 1위는 1/2, 2위는 1/3, 3위는 1/4을 받아요.
예를 들어서 상품 A는 dense 1위, sparse 2위이고, 상품 C는 sparse 1위인데 dense 결과에는 없다고 해볼게요. 상품 B는 dense 2위, sparse 3위고요. Qdrant 기본값으로 계산하면 이렇게 돼요.
- A: 1/2 + 1/3 = 0.833
- B: 1/3 + 1/4 = 0.583
- C: 1/2 + 0 = 0.5
C는 sparse에서 1위였지만 한쪽에서만 값을 받아서 3위로 내려가요. A와 B처럼 두 목록 모두에서 위에 있는 상품이 앞에 서는 거예요.
점수를 0~1로 맞춘 뒤 더하는 방법(Qdrant의 DBSF, Weaviate가 1.24부터 기본으로 쓰는 relativeScoreFusion)도 있지만, 이 글의 실측은 RRF만 썼어요.
실측: dense, sparse, 하이브리드 비교
이 부분은 제가 실측한 결과를 기반으로 알려드릴게요. 상품 검색엔진을 만들면서 같은 상품 1만 개를 dense만, sparse만, 둘을 RRF로 합친 하이브리드로 각각 검색해 비교했어요.
조건은 이렇습니다.
- 상품: LLM으로 만든 의류 상품 1만 개. 이름, 종류, 색상, 소재, 핏, 스타일, 설명을 이어 붙인 문장을 벡터로 바꿨어요.
- dense: multilingual-e5-small(384차원)
- sparse: 공백과 기호로 자른 단어 횟수, Qdrant의 IDF 옵션을 켬
- 하이브리드: dense와 sparse에서 상위 50개씩 가져와 Qdrant의 RRF로 합침
검색어는 다섯 종류예요.
- 속성: LLM이 상품 속성 두세 개로 쓴 자연스러운 검색어 280개
- 영문 브랜드: "loopnine 베이지 점퍼"처럼 영문 브랜드 + 색 + 종류 98개
- 한글 브랜드: "아이보리홀 핑크 니트웨어"처럼 한글 브랜드 + 색 + 종류 99개
- 가격 조건: "화이트 스커트 5만원 이하" 같은 검색어 96개
- 모델번호:
PF-01825C같은 번호만 친 검색어 100개
점수는 recall@10이에요. 상위 10개 안에 정답 상품이 몇 개 들었는지를 비율로 낸 값이고, 정답이 10개보다 적으면 정답 수로 나눠요. 1.0이면 정답을 다 찾은 거예요.
검색어 종류별 recall@10은 이렇게 나왔어요(dense / sparse / 하이브리드 순서).
- 모델번호: 0.8 / 1.0 / 1.0
- 속성: 0.7785 / 0.7148 / 0.7915
- 가격 조건: 0.6493 / 0.7471 / 0.7306
- 한글 브랜드: 0.7057 / 0.6691 / 0.7515
- 영문 브랜드: 0.8265 / 0.6096 / 0.8102
모델번호부터 볼게요. dense는 다섯 개 중 하나꼴로 정답을 놓쳤는데, sparse와 하이브리드는 모두 찾았어요. 앞에서 본 PF-00410A 문제가 그대로 풀린 거예요.
속성 검색어는 반대예요. sparse가 0.7148로 dense 0.7785보다 낮았어요.
그런데 하이브리드는 0.7915로 dense보다 조금 높았어요. 모델번호를 다 찾으면서 속성 검색어 점수도 지킨 거예요.
가격 조건과 한글 브랜드도 하이브리드가 dense보다 올랐어요.
반면 영문 브랜드는 하이브리드가 0.8102로 dense 0.8265보다 조금 낮았습니다. 상품 이름에 한글 브랜드만 적힌 상품은 영문 브랜드 검색어와 글자가 겹치지 않아요. 그래서 sparse 점수가 0.6096으로 낮았고, 이게 합친 결과를 끌어내린 것으로 보여요.
토크나이저도 비교해 봤어요. 하이브리드 기준으로 Kiwi 형태소 분석기를 쓴 쪽은 공백 방식보다 다섯 종류 모두에서 같거나 낮았어요.
한글 브랜드를 sparse만으로 찾을 때는 공백 방식 0.6691, Kiwi 0.5841이었어요. 앞에서 본 "루프나인"이 "루프나"로 잘리는 일이 점수로 나타난 거예요.
검색 시간도 볼게요. 검색어 하나의 중간값이 sparse만 쓰면 2ms 안팎, dense는 25ms 안팎, 하이브리드는 25~28ms였어요. 시간의 대부분이 검색어를 임베딩하는 데 드는 것으로 보이고, sparse를 더해도 검색이 크게 느려지지 않았어요.
하나 알아 두실 점이 있어요. 하이브리드 점수는 같은 측정을 다시 돌려도 소수 셋째 자리에서 조금씩 달라질 수 있었어요(dense는 늘 같았어요). 원인은 확인하지 못했고, 그래서 0.002 안팎의 차이는 같다고 봤어요.
모델과 데이터에 따라 결과가 달라질 수 있어요
위 결과는 384차원의 작은 모델로, 상품 1만 개에서 번호만 친 검색어 기준이에요. 모델과 데이터가 다르면 dense만으로도 식별자를 잘 찾기도 해요.
이 사이트의 다른 실측에서는 bge-m3(1024차원) dense 벡터가 오류 코드·설정 이름 같은 식별자 질문 25개를 모두 1위로 찾았어요. 같은 실측에서 multilingual-e5-small도 25개 모두 상위 5개 안에 찾았고, RRF로 BM25를 섞어도 모든 지표가 함께 오르지는 않았어요. 이 부분이 더 궁금하면 벡터 검색은 정말 오류 코드를 못 찾아 BM25가 필요할까? 글을 참고하세요.
언제 하이브리드로 써야 할까요
- 검색어에 모델번호, 상품 코드, 오류 코드, 설정 이름처럼 글자 그대로 맞아야 하는 문자열이 자주 들어오면 sparse 벡터를 더하세요.
- 검색어가 대부분 자연스러운 문장이고 표현이 제각각이면 dense 벡터(Semantic Search)가 중심이에요. sparse만으로는 동의어를 못 찾아요.
- 두 종류가 섞여 들어오는 검색엔진, 예를 들어 상품 검색이라면 하이브리드로 시작하고 RRF로 합치는 게 무난해요. 점수 범위를 맞출 필요가 없거든요.
- 한국어 sparse 벡터의 토크나이저는 데이터를 보고 고르세요. 사전에 없는 브랜드가 많으면 형태소 분석기가 오히려 불리할 수 있어요.
- 모델과 데이터에 따라 하이브리드의 이득이 작을 수 있어요. 검색어 종류별로 나눈 평가셋으로 dense 단독과 하이브리드를 비교한 뒤 정하세요.
정리
범위: dense 벡터와 sparse 벡터의 개념, RRF로 합치는 하이브리드 검색, 의류 상품 1만 개와 multilingual-e5-small로 잰 Qdrant 1.19.1 실측.
- dense 벡터는 문장의 뜻을 숫자 목록으로 담아 Semantic Search를 해요. 실측에서 모델번호 검색어 recall@10은 0.8이었고,
PF-00410A를 치면 숫자 하나 다른PF-00910A가 1위였어요. - sparse 벡터는 단어 번호 자리에 횟수를 넣은, 대부분이 0인 벡터예요. 글자가 겹쳐야 점수가 나서 모델번호는 1.0으로 다 찾았지만, 속성 검색어는 0.7148로 dense(0.7785)보다 낮았어요.
- IDF는 흔한 단어의 가중치를 낮추고 드문 단어를 높여요. 모델번호처럼 한 문서에만 나오는 단어가 검색 결과를 끌어올리게 하려면 켜 두세요.
- RRF는 점수 대신 순위로 합쳐서 범위가 다른 두 점수를 맞출 필요가 없어요. 하이브리드는 모델번호 1.0을 지키면서 속성 검색어도 0.7915로 dense보다 조금 높았어요.
- 하이브리드가 늘 이기지는 않아요. 영문 브랜드 검색어는 0.8102로 dense(0.8265)보다 낮았고, 다른 모델과 데이터에서는 dense만으로 식별자를 다 찾은 실측도 있어요. 검색어 종류별로 나눠 재 본 뒤 정하세요.