시맨틱 vs 키워드 vs 하이브리드 검색: 모든 RAG 데모가 놓치고 있는 것
요약
RAG 시스템에서 시맨틱 검색과 키워드 검색의 한계를 분석하고, 이를 보완하는 하이브리드 검색의 필요성을 설명합니다. BM25 알고리즘의 원리와 임베딩 기반 시맨틱 검색의 차이점을 다루며 실무적인 검색 전략을 제시합니다.
핵심 포인트
- 순수 시맨틱 검색은 고유 명사나 에러 코드 검색에 취약함
- 키워드 검색은 유의어나 개념적 질의를 포착하지 못함
- BM25는 단어 빈도와 역문서 빈도를 활용한 효율적인 랭킹 방식임
- 하이브리드 검색은 두 방식의 장점을 결합하여 검색 정확도를 높임
원래 제 블로그에 게시되었습니다. 정식 링크(canonical link)와 함께 이곳에 교차 게시되었습니다.
오디오를 선호하시나요? Spotify 에피소드 · Telegram
모든 RAG 튜토리얼은 동일한 방식으로 시작됩니다: 문서를 청킹(chunk)하고, 임베딩(embed)하고, 벡터 스토어(vector store)에 넣고, 코사인 유사도(cosine similarity)로 쿼리합니다. 끝입니다.
훌륭한 데모입니다. 하지만 실제 진지한 검색 시스템이 작동하는 방식은 아닙니다.
사용자가 error code E_1042나 Llama-3.1-70B 또는 제품 SKU를 입력하는 순간, 순수 시맨틱 검색(semantic search)은 조용히 실패하기 시작합니다. E_1042의 임베딩은 노이즈 벡터이기 때문입니다. 반면 키워드 검색(keyword search)은 정반대의 문제를 가지고 있습니다: _"how do I cancel my subscription"_이라고 입력하면 _"ending your membership"_이라는 제목의 문서를 놓칩니다.
그래서 실제 시스템은 두 가지를 모두 사용합니다. 이 포스트는 각각의 방식이 실제로 무엇을 하고 있는지, 왜 하이브리드(hybrid) 방식이 단독 방식보다 뛰어난지, 그리고 Postgres에서 약 40줄의 코드로 이를 어떻게 구축할 수 있는지에 대해 다룹니다.
키워드 검색: 한 단락으로 요약한 BM25
키워드 검색은 쿼리 용어를 포함하는 문서를 찾습니다. 문제는 그것들을 어떻게 랭킹(rank) 하느냐입니다.
BM25(90년대 이후 사실상의 표준 랭킹 함수)는 기본적으로 세 가지 아이디어가 쌓여 있습니다:
- 단어 빈도 (Term frequency) — 문서에 단어가 많이 나타날수록 더 관련성이 높지만, 수확 체감의 법칙이 적용됩니다 (예: "database"가 20번째 나타난다고 해서 큰 도움이 되지는 않습니다).
- 역문서 빈도 (Inverse document frequency) — 희귀한 단어일수록 더 중요합니다. _"the"_는 쓸모없지만, _"pgvector"_는 강력한 신호입니다.
- 길이 정규화 (Length normalization) — 그렇지 않으면 긴 문서가 우연히 점수를 더 높게 받을 수 있으므로, 길이에 따라 점수를 정규화합니다.
그게 전부입니다. 머신러닝(machine learning)도, GPU도, 학습(training)도 필요 없습니다. 내부적으로는 역색인(inverted index, 단어 → 해당 단어를 포함하는 문서 목록) 구조를 사용하여 수십억 개의 문서에서도 눈부시게 빠릅니다.
장점: 정확한 일치(exact matches), ID, 희귀 토큰(rare tokens), 약어, 제품명, 파일명, 버전 번호 등 리터럴(literal)한 모든 것.
실패하는 부분: 유의어 ("car" ≠ "automobile"), 의역(paraphrase), 개념적 질의(conceptual queries), 교차 언어(cross-language).
시맨틱 검색 (Semantic Search): 한 단락으로 보는 임베딩 (Embeddings)
임베딩 모델 (embedding model)은 텍스트를 벡터(vector) — 예를 들어 768개의 부동 소수점(floats) — 로 변환하며, 이를 통해 의미가 유사한 텍스트들이 해당 벡터 공간(vector space) 내에서 서로 가까이 위치하게 합니다. _"How do I cancel?"_와 _"ending your subscription"_은 서로 근접한 이웃(near-neighbors)이 됩니다.
검색을 위해서는 질의(query)를 임베딩하고 가장 가까운 벡터들을 찾습니다. 단순하게 구현하면 모든 문서와의 거리를 확인해야 하므로 $O(N)$의 복잡도가 발생하며, 이는 수백만 개 이상의 문서로 확장(scale)하기 어렵습니다. 따라서 우리는 근사 최근접 이웃 (approximate nearest neighbor, ANN) 인덱스를 사용합니다:
- HNSW (Hierarchical Navigable Small World) — 각 노드가 여러 해상도에서 근접 이웃과 연결되는 그래프 구조입니다. 빠르고 정확하지만, 메모리 소모가 큽니다.
- IVF (Inverted File) — 먼저 모든 벡터를 클러스터링(cluster)한 뒤, 질의 시점에 가장 가까운 클러스터들만 검색합니다. 더 가볍지만, 정확도는 약간 낮습니다.
장점: 의역(paraphrase), 유의어(synonyms), 개념적 유사성(conceptual similarity), 교차 언어(cross-language), 모호한 의도(fuzzy intent).
실패하는 부분: 희귀 토큰(rare tokens) (임베딩 모델이 본 적 없는 토큰), 약어(acronyms), 식별자(identifiers), 숫자(numbers), 정확한 일치(exact-match) 요구 사항. 또한 비용이 많이 듭니다. 모든 질의마다 임베딩 모델 호출과 벡터 검색이 동시에 이루어져야 하기 때문입니다.
하이브리드 검색이 두 방식 모두를 압도하는 이유
동일한 질의를 두 시스템 모두에 실행하면 두 개의 순위 리스트(ranked lists)를 얻게 됩니다. 이 둘을 어떻게 병합할까요?
벤치마크에서 계속해서 승리하고 있는 놀라울 정도로 간단한 해답은 바로 상호 순위 결합 (Reciprocal Rank Fusion, RRF) 입니다. 임의의 랭커(ranker) $r$에 나타나는 각 문서 $d$에 대해, 모든 랭커에 걸쳐 $\sum 1 / (k + \text{rank}_r(d))$를 합산합니다:
┌── 키워드 랭커 (keyword ranker, BM25) ──┐
query ────┤ ├──► RRF 병합 ──► 최종 순위 (final ranking)
└── 시맨틱 랭커 (semantic ranker, vec) ──┘
...
$k$는 평활화 상수 (smoothing constant) 로, 일반적으로 60을 사용합니다. 이는 1위와 2위 사이의 격차를 완화하여, 최상위 결과가 결합된 점수(fused score)를 완전히 독점하지 않도록 합니다. $k$를 높이면 낮은 순위의 기여도가 높아지고, $k$를 낮추면 상위 순위가 지배하게 됩니다. 기본값은 거의 모든 경우에 잘 작동합니다.
def rrf(ranked_lists, k=60):
scores = {}
for ranking in ranked_lists:
...
점수 척도(score scales) 간의 보정(calibration)도, 튜닝도, 학습도 필요하지 않습니다. 어느 한 리스트에서 순위가 높은 문서는 위로 떠오르고, 두 리스트 모두에서 순위가 높은 문서는 매우 강력하게 위로 떠오릅니다. 이것이 RRF가 작동하는 이유입니다. 두 시스템은 서로 다른 실패 모드(failure modes)를 가지고 있으며, RRF는 그 점을 활용합니다.
Postgres에서 구현하기
Postgres는 키워드 검색(tsvector + GIN 인덱스 활용)과 시맨틱 검색(pgvector + HNSW 인덱스 활용)을 모두 네이티브하게 지원합니다. 하나의 테이블, 두 개의 인덱스, 하나의 하이브리드 쿼리로 해결됩니다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE docs (
...
주의 사항:
HNSW를 사용하려면 pgvector ≥ 0.5.0 버전이 필요합니다. 이전 버전을 사용 중이라면HNSW대신IVFFLAT으로 교체하세요. 정확도는 약간 떨어지지만 쿼리 측면에서는 동일하게 작동합니다.
이제 RRF를 사용한 하이브리드 쿼리를 단 하나의 SQL 문으로 실행할 수 있습니다:
WITH kw AS (
SELECT id, row_number() OVER (ORDER BY ts_rank_cd(content_tsv, query) DESC) AS rnk
FROM docs, plainto_tsquery('english', 'how to cancel subscription') query
...
$1은 쿼리 임베딩(query embedding)입니다. 이는 애플리케이션 레이어(Python, Node, Go)에서 임베딩 모델을 호출하여 이미 계산한 VECTOR(768) 값입니다. Postgres는 스스로 임베딩을 수행하지 않으며, 단지 그 결과를 저장하고 검색할 뿐입니다. 따라서 귀하의 앱은 다음과 같이 동작합니다: (1) 쿼리를 임베딩하고, (2) 해당 벡터를 이 SQL의 파라미터로 전송합니다. 이것이 바로 귀하의 하이브리드 리트리버(hybrid retriever)입니다. 하나의 데이터베이스, 하나의 쿼리, 추가 인프라 불필요.
Elasticsearch, Qdrant, Weaviate는 어떤가요?
Postgres는 대부분의 팀에게 훌륭한 선택입니다. 규모(scale), 유연성, 또는 특정 기능이 Postgres의 한계를 넘어서게 될 때 전용 도구들이 중요해집니다.
| 도구 (Tool) | 강점 (Best at) | 약점 (Weak at) |
|---|---|---|
| Postgres (pgvector + tsvector) | 이미 Postgres를 사용 중인 팀, 중간 규모 (< ~1,000만 개 벡터), 임베딩 (embeddings) 옆에 트랜잭션 데이터 (transactional data)가 있는 경우 | 수십억 규모의 벡터 검색, 복잡한 BM25 튜닝 (tuning), 멀티 테넌트 리랭킹 (multi-tenant reranking) |
| ... | ||
| 경험 법칙 (Rule of thumb): Postgres로 시작하세요. 벡터 수가 약 1,000만 개를 넘거나 높은 초당 쿼리 수 (QPS)에서 낮은 지연 시간의 근사 최근접 이웃 (ANN) 검색이 필요할 때 Qdrant 또는 Weaviate로 이동하세요. 키워드 품질과 패시팅 (faceting)이 제품의 핵심일 때는 Elasticsearch/OpenSearch를 사용하세요. 세 가지 차원 모두가 중요하고 다른 어떤 것도 확장성을 제공하지 못할 때는 Vespa를 고려하세요. |
결정 매트릭스 (Decision Matrix)
| 쿼리가 다음과 같다면… | 선택할 도구 | 이유 |
|---|---|---|
| 제품 SKU, 에러 코드, 버전 번호, 파일 이름 | 키워드 (Keyword) | 임베딩 (embeddings)은 이러한 정확한 토큰 (tokens)을 본 적이 없습니다. BM25는 이를 높은 IDF 신호로 처리합니다. |
| ... | ||
| 확신이 서지 않는다면 — 그리고 실제 운영 환경 (production)에서는 대개 확신이 서지 않기 마련입니다 — 그냥 하이브리드 (hybrid)를 사용하세요. RRF (Reciprocal Rank Fusion)는 단점이 없습니다. 특정 쿼리에 대해 한쪽 방식이 쓸모없다면, 단순히 기여도가 거의 0에 수렴하게 되어 다른 쪽 방식이 랭킹에서 승리하게 됩니다. |
아무도 말해주지 않는 주의사항 (Gotchas Nobody Tells You)
- 청킹 (Chunking)이 임베딩 모델 (Embedding model)보다 더 중요합니다. 잘못 청킹된 문서에 적용된 훌륭한 모델은 잘 청킹된 문서에 적용된 평범한 모델에게 패배합니다. 약 300-토큰 (token) 크기의 청크와 오버랩 (overlap)을 사용하는 것부터 시작하세요.
- 다국어 콘텐츠는 키워드 검색 (Keyword search)을 망가뜨립니다.
to_tsvector('english', ...)는 영어가 아닌 텍스트를 소리 없이 망가뜨립니다. 언어를 감지하여 적절한 사전 (dictionary)을 사용하거나, 시맨틱 (semantic) 방식에 더 의존하세요. - 불용어 (Stop words)는 양날의 검입니다. _"the"_를 제거하는 것은 키워드 검색에 도움이 됩니다. 하지만 _"not"_을 제거하는 것은 시맨틱 방식에서 의미를 완전히 바꿔버립니다.
- 리랭커 (Reranker)가 튜닝 (Tuning)보다 효과적입니다. 하이브리드 (Hybrid) 검색이 50개의 후보를 검색해낸 후, 크로스 인코더 (Cross-encoder) 리랭커 (예:
bge-reranker)가 쿼리 (query)와 대조하여 이들을 쌍별로 (pairwise) 재점수화합니다. 이는 오후 한나절 만에 추가할 수 있는 가장 큰 품질 향상 요소입니다. - 지연 시간 (Latency) 예산은 제품 결정 사항입니다. 순수 키워드 검색: ~5ms. HNSW를 사용한 순수 시맨틱 검색: ~20ms. 리랭커를 포함한 하이브리드 검색: ~200ms. 마지막 방식은 타입어헤드 (Typeahead) 기능에는 적합하지 않지만, RAG에는 적합할 수 있습니다.
결론 (Closing)
"시맨틱 대 키워드"라는 프레임은 잘못된 선택지입니다. 이들은 상호 보완적이며, 각 방식은 상대방이 보지 못하는 부분을 보완합니다. RRF를 활용한 하이브리드 검색 (Hybrid retrieval)은 추가 비용이 거의 들지 않고, 별도의 학습이 필요 없으며, BM25를 지원하는 모든 벡터 스토어 (Vector store)에서 작동합니다.
한 가지만 기억하세요. 새로운 벡터 데이터베이스 (Vector database)를 선택하기 전에, Postgres가 이미 당신에게 필요한 기능을 수행하고 있는지 확인하십시오. 대부분의 팀에게는 그렇습니다.
다음에 누군가가 임베딩 (Embeddings)만 사용한 RAG 데모를 보여준다면, 당신은 무엇이 빠져 있는지 알게 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기