RAG 성능 고도화 — 검색 알고리즘: 희소·밀집·앙상블
이 글은 RAG의 '검색단'을 책으로 공부하며 정리한 기록이다. 키워드로 찾는 희소검색, 뜻으로 찾는 밀집검색, 그리고 둘을 섞는 앙상블까지 — 검색 알고리즘 선택이 RAG 정확도를 어떻게 가르는지 짚는다. 책과 Gemini의 도움을 빌려 궁금증을 해소해가며 쌓았다.
키워드냐 의미냐, 아니면 둘 다냐
검색 알고리즘이라고 거창하게 부르지만, 하는 일은 단순하다. 사용자가 던진 질문과 내가 가진 참조 문서들을 비교해서 "이 질문에 가장 잘 맞는 문서가 어떤 거냐"를 골라내는 로직이다. 그런데 이 '잘 맞는다'를 무엇으로 판단하느냐에 따라 결과가 완전히 갈린다. 글자가 똑같이 생겼는지를 볼 수도 있고, 글자는 달라도 뜻이 통하는지를 볼 수도 있다. RAG 시스템의 정확도가 이 선택 하나에서 크게 달라지기 때문에, 검색 단을 어떻게 짜느냐가 결국 전체 품질을 좌우한다고 봐도 된다.
여기서 다룰 건 세 가지다. 글자 일치를 보는 희소검색, 뜻이 통하는지를 보는 밀집검색, 그리고 이 둘을 적당히 버무리는 앙상블검색. 앞에서부터 하나씩 풀어보겠다.
희소검색 — 글자가 똑같이 생겼는가
가장 먼저 떠올릴 수 있는 방식은 "질문에 들어 있는 단어가 문서에도 그대로 들어 있나"를 보는 것이다. 이게 희소검색(Sparse Retrieval)이고, 대표 주자가 BM25라는 키워드 기반 알고리즘이다. 질문과 문서 사이에 겹치는 키워드가 많을수록 관련성이 높다고 점수를 매긴다. 구조가 단순하고 빠르다는 게 강점이지만, 뒤집어 말하면 의미적인 유사성은 잡아내지 못한다. '자동차'로 검색하면 '차량'이라고만 적힌 문서는 그냥 못 찾는다. 글자가 다르니까.
그리고 한국어를 쓰는 순간 여기서 한 가지 함정이 더 튀어나온다. 영어는 단어가 공백으로 깔끔하게 끊어지지만, 한국어는 그렇지가 않다(중국어는 아예 띄어쓰기가 없다). 단어 뒤에 조사가 찰싹 붙어 다니기 때문이다. 그래서 한국어로 BM25를 제대로 쓰려면 형태소 분석기로 문장을 품사 단위(명사·동사 등)로 미리 잘라주는 전처리가 사실상 필수다.
한국어에서 형태소 분석기는 '선택'이 아니라 '필수'다
이게 왜 선택이 아니라 필수인지, 가장 간단한 예로 보여주는 게 '사과 vs 사과가' 문제다. BM25는 앞서 말했듯 "글자 뭉치가 똑같이 생겼는가"만 따지는 알고리즘이다. 그러니 전처리를 안 하고 그냥 띄어쓰기 기준으로 자르면 이런 일이 벌어진다.
문서 A가 "나는 사과를 먹었다"라면 토큰은 ['나는', '사과를', '먹었다.']로 쪼개진다. 그런데 사용자가 "사과 있어?"라고 물으면 토큰은 ['사과']다. BM25 입장에서 사과를과 사과는 글자가 다르니까 서로 다른 단어다. 결과적으로 검색에 실패하거나 점수가 형편없이 낮게 나온다.
여기에 Kiwi 같은 형태소 분석기를 끼우면 이야기가 달라진다. "나는 사과를 먹었다"는 ['나', '는', '사과', '를', '먹다']로, "사과 있어?"는 ['사과', '있다']로 분해된다. 이제 사과라는 핵심 형태소가 양쪽에서 정확히 일치한다. 검색 성공이다.
형태소 분석기가 뒤에서 해주는 일은 크게 세 가지로 정리된다. 첫째는 조사 제거다. '가', '이', '를', '은', '는' 같은 조사를 떼어내고 '사과', '학교', '투자' 같은 명사만 남긴다. 둘째는 어간 추출(Stemming)이다. '먹었다', '먹고', '먹으니'를 전부 '먹다'라는 기본형 하나로 통일한다. 셋째는 복합명사 분해다. '주식시장'을 '주식'과 '시장'으로 나눠두기 때문에, '주식'으로만 검색해도 '주식시장'이 들어간 문서까지 걸린다.
그럼 "이럴 거면 그냥 의미를 파악하는 벡터 검색(밀집검색)만 쓰면 되는 거 아니냐"는 의문이 들 수 있다. 맞는 말이다. 벡터 검색은 형태소 분석 없이도 의미를 잡는다. 그럼에도 BM25를 굳이 같이 쓰는 이유가 있다. "삼성전자", "iPhone 15 Pro" 같은 고유명사는 의미보다 글자 그대로 정확히 일치하는 게 훨씬 중요한데, 이런 건 키워드 검색이 압도적으로 강하다. 결국 벡터 검색이 흘려보내는 '정확한 키워드'를 BM25가 받쳐주는 보완재로 쓰는 셈이다. 이게 뒤에 나올 하이브리드 전략의 씨앗이기도 하다.
밀집검색 — 글자는 달라도 뜻이 통하는가
희소검색의 한계가 명확해졌으니, 자연스럽게 반대편 접근이 필요해진다. 글자가 아니라 뜻으로 비교하는 방식, 그게 밀집검색(Dense Retrieval)이다. 질문과 문서를 둘 다 고차원 벡터 공간에 임베딩(숫자 좌표로 변환)해서, 그 좌표가 얼마나 가까운지로 관련성을 따진다. 키워드가 단 하나도 안 겹쳐도 의미가 통하면 잡아낸다. 우리가 흔히 말하는 '의미 기반 검색'의 본질이 바로 이거다. 대신 공짜는 아니어서, 임베딩을 계산하는 비용이 들고 대규모 데이터를 다룰 땐 그 부담이 만만치 않다.
이 방식이 실제로 어떻게 굴러가는지는 '오프라인'과 '온라인' 두 단계로 나눠 보면 깔끔하게 잡힌다.
flowchart TD
subgraph OFFLINE["오프라인 — 미리 (색인 단계)"]
A["문서 준비"] --> B["청킹<br/>(문서를 작은 조각으로 분할)"]
B --> C["임베딩 모델로 각 조각 벡터화"]
C --> D[("Vector DB / FAISS에 색인 저장")]
end
subgraph ONLINE["온라인 — 질문 시 (쿼리 단계)"]
E["사용자 질문 입력"] --> F["문서와 동일한 임베딩 모델로 쿼리 벡터화"]
F --> G["고차원 공간의 좌표로 표현"]
G --> H["저장된 문서 벡터들과 유사도 비교"]
H --> I["가장 가까운 문서 반환"]
end
D -- "색인 완료 후 서비스 제공" --> E
먼저 오프라인, 즉 질문이 들어오기 전에 미리 해두는 작업이다. 수백만 개 문서를 다룬다고 해보자. 사용자가 질문할 때마다 그 많은 문서를 그 자리에서 다 읽어 숫자로 바꾸는 건 현실적으로 불가능하다. 그래서 전처리를 미리 끝내둔다. 문서를 잘게 쪼개고(청킹), 임베딩 모델을 돌려 각 조각을 숫자 좌표로 바꾼 다음, 이 좌표들을 Vector DB (FAISS, Chroma등)에 미리 저장(색인)해둔다. 핵심은 이 과정이 질문이 들어오기 전에 전부 끝난다는 점이다. 도서관에 책을 미리 분류해서 꽂아두는 것과 똑같다.
그다음이 온라인, 사용자가 질문을 던지는 바로 그 순간이다. 이때 시스템은 딱 그 질문 하나만 숫자로 바꾼다. 질문이 고차원 공간(예컨대 768차원) 위의 한 점으로 찍히고, 미리 저장해둔 수백만 개의 점들 중에서 내 질문 점과 가장 가까운 녀석들을 찾아낸다. 문서 전부를 매번 질문과 함께 임베딩하지 않는 이유도 여기서 분명해진다. 매번 수백만 문서를 통째로 모델에 욱여넣으면 서버가 버티질 못한다. 그래서 무거운 문서 쪽은 미리 처리해두고, 가벼운 질문만 그때그때 처리하는 것이다.
질문은 반드시 '문서와 똑같은 임베딩 모델'에 넣어야 한다
여기서 절대 놓치면 안 되는 게 하나 있다. 사용자의 질문을 임베딩할 때는 문서를 임베딩할 때 썼던 것과 똑같은 모델을 써야 한다. 이건 정말 중요하다.
이유는 임베딩 모델마다 '뇌구조'가 다르기 때문이다. 각 모델은 사전학습 단계에서 학습한 데이터셋이 제각각이라, 같은 단어를 받아도 벡터 공간의 다른 위치에 점을 찍는다. 동작 방식 자체가 다른 것이다. 문서는 A 모델 기준 좌표계에 찍어놓고 질문은 B 모델 기준 좌표계에 찍으면, 두 점은 서로 다른 지도 위에 있는 셈이라 거리를 재는 것 자체가 무의미해진다. 같은 자로 재야 비교가 되는 것과 같은 이치다. (또한 모델의 토크나이저 또한 다르다.)
앙상블검색 — 두 방식의 장점만 섞는다
여기까지 오면 결론이 자연스럽게 보인다. 희소검색은 정확한 키워드에 강하지만 뜻을 못 읽고, 밀집검색은 뜻을 읽지만 정확한 글자 매칭이 약하다. 서로의 약점이 상대의 강점이다. 그럼 둘을 적당히 섞어서 밸런스를 맞추면 되지 않을까. 그게 앙상블검색이고, 하이브리드 검색이라고도 부른다.
flowchart TD
A["사용자 질문 입력"] --> B{"병렬 검색"}
B --> C["희소 검색<br/>(BM25 키워드 기반)"]
B --> D["밀집 검색<br/>(벡터 의미 기반)"]
C --> E["가중치 부여<br/>(기술·법률 문서면 키워드 비중↑)"]
D --> F["가중치 부여<br/>(기본 0.5 : 0.5)"]
E --> G["두 결과를 가중 결합·재정렬"]
F --> G
G --> H["최종 문서 선별"]
핵심은 두 검색기를 동시에 돌리되, 각각에 가중치를 어떻게 줄지를 상황에 맞게 조절하는 데 있다. 기술 문서나 법률 문서처럼 정확한 용어 매칭이 생명인 경우에는 희소검색(키워드 기반)에 가중치를 더 실어주는 게 맞다. 용어 하나 틀리면 의미가 통째로 어긋나는 영역이니까. 반대로 일상적이고 일반적인 질문을 다루는 상황에서는 의미 기반 검색에 더 높은 비중을 두는 편이 낫다. 이 비율을 도메인 성격에 따라 손으로 조정하는 게 앙상블 설계의 핵심이다.
세 방식을 한자리에 놓고 비교하면 차이가 더 또렷하다.
| 구분 | 희소검색 (BM25) | 밀집검색 (벡터) | 앙상블 (하이브리드) |
|---|---|---|---|
| 원리 | 질문·문서의 키워드 일치도로 점수 | 질문·문서를 벡터로 바꿔 의미적 거리 비교 | 두 방식을 가중치로 결합 |
| 장점 | 빠르고 단순, 고유명사·정확한 키워드에 강함 | 글자가 달라도 뜻이 통하면 포착 | 두 방식의 강점을 모두 흡수, 적중률 극대화 |
| 단점 | 의미적 유사성 포착 불가, 한국어는 형태소 전처리 필수 | 임베딩 계산 비용·자원 소모가 큼 | 두 검색기를 다 돌려야 해 구성이 무겁고 가중치 튜닝 필요 |
| 적합한 상황 | 고유명사·전문 용어가 핵심인 검색 | 표현이 다양하고 의미 위주인 일반 질문 | 키워드와 의미가 둘 다 중요한 실전 환경 |
지금까지 본 희소·밀집·앙상블 세 가지는 사실 따로 노는 기법이 아니다. 코드로 보면 BM25 검색기를 만들고, 벡터 검색기를 만든 다음, 이 둘을 앙상블 검색기로 묶는 한 줄기 흐름으로 자연스럽게 이어진다. 아래 통합 예제에서 세 검색기가 같은 질문에 어떻게 다르게 반응하는지, 그리고 가중치를 어디서 조절하는지를 한눈에 확인할 수 있다.
"""
희소검색(BM25) vs 밀집검색(코사인 유사도) vs 앙상블 검색 비교 예제
도메인: 요리 레시피 검색
실제로는 BM25 라이브러리(rank_bm25)와 임베딩 모델(SentenceTransformers 등)을 사용한다.
여기서는 표준 라이브러리만으로 핵심 개념을 구현한다.
"""
import math
from collections import Counter
# ──────────────────────────────────────────────
# 검색 대상 요리 레시피 문서 코퍼스
# ──────────────────────────────────────────────
DOCS = [
# 0: 키워드 "파스타"가 많이 등장 → BM25에 유리
{
"id": 0,
"title": "토마토 파스타",
"text": "파스타 면을 삶은 뒤 토마토 소스와 버무린다. "
"파스타는 알덴테로 삶는 것이 중요하다. "
"토마토 퓨레, 올리브오일, 마늘로 소스를 만들고 파스타 위에 얹는다.",
},
# 1: "파스타"라는 단어 없이 의미적으로 유사 → 밀집검색에 유리
{
"id": 1,
"title": "크림 뇨키",
"text": "감자 반죽을 빚어 만든 작은 이탈리아 덩어리를 크림 소스에 버무린다. "
"이탈리아 전통 면 요리의 변형으로 부드럽고 진한 맛이 특징이다.",
},
# 2: 완전히 다른 요리
{
"id": 2,
"title": "된장찌개",
"text": "두부와 애호박을 넣고 된장을 풀어 끓인다. "
"멸치 육수를 베이스로 사용하면 깊은 맛이 난다. 한국 전통 국물 요리다.",
},
# 3: "올리브오일" 키워드가 겹침
{
"id": 3,
"title": "그리스 샐러드",
"text": "올리브오일과 레몬즙으로 드레싱을 만든다. "
"토마토, 오이, 페타 치즈를 올리브오일에 버무리면 완성이다.",
},
# 4: 의미적으로 이탈리아 면 요리에 가까움
{
"id": 4,
"title": "라자냐",
"text": "납작한 밀가루 면 사이에 고기 소스와 베샤멜 소스를 켜켜이 쌓아 오븐에 굽는다. "
"이탈리아 오븐 요리로 파티 음식으로 인기가 많다.",
},
]
# ──────────────────────────────────────────────
# 공통 유틸: 간단한 토크나이저 (공백 분리 + 간이 조사 제거)
# 실제로는 형태소 분석기(Kiwi 등)를 사용한다
# ──────────────────────────────────────────────
def tokenize(text: str) -> list[str]:
"""공백 기준으로 토큰화하고 조사·구두점을 제거한다 (간이 전처리)"""
tokens = []
for word in text.split():
word = word.strip(".,!?")
# 간단히 마지막 한 글자가 조사성 글자면 제거 (실제는 형태소 분석기 사용)
if len(word) > 1 and word[-1] in "은는이가을를의에서로와과":
word = word[:-1]
if word:
tokens.append(word)
return tokens
# ══════════════════════════════════════════════
# 1. 희소검색 (BM25)
# 키워드 겹침 빈도 기반으로 점수를 매긴다
# 실제로는 rank_bm25 라이브러리의 BM25Okapi를 사용한다
# ══════════════════════════════════════════════
class SimpleBM25:
"""BM25 Okapi 알고리즘의 간이 구현"""
def __init__(self, documents: list[str], k1: float = 1.5, b: float = 0.75):
self.k1 = k1
self.b = b
self.tokenized_docs = [tokenize(doc) for doc in documents]
self.doc_count = len(self.tokenized_docs)
self.avgdl = sum(len(d) for d in self.tokenized_docs) / self.doc_count
# 역문서빈도(IDF) 계산을 위한 DF 집계
self.df: dict[str, int] = {}
for tokens in self.tokenized_docs:
for term in set(tokens):
self.df[term] = self.df.get(term, 0) + 1
def _idf(self, term: str) -> float:
"""IDF: 드물게 등장하는 단어일수록 높은 가중치"""
df = self.df.get(term, 0)
return math.log((self.doc_count - df + 0.5) / (df + 0.5) + 1)
def score(self, query: str) -> list[float]:
"""쿼리에 대한 각 문서의 BM25 점수를 반환한다"""
query_tokens = tokenize(query)
scores = []
for tokens in self.tokenized_docs:
tf_map = Counter(tokens)
dl = len(tokens)
doc_score = 0.0
for term in query_tokens:
tf = tf_map.get(term, 0)
idf = self._idf(term)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
doc_score += idf * (numerator / denominator)
scores.append(doc_score)
return scores
# ══════════════════════════════════════════════
# 2. 밀집검색 (코사인 유사도 기반)
# 실제로는 OpenAIEmbeddings/SentenceTransformers로 수백 차원 벡터를 만들어
# FAISS로 인덱싱한다. 여기서는 문자 2-gram TF 벡터를 간이 임베딩으로 쓴다.
# ══════════════════════════════════════════════
def char_ngram_vector(text: str, n: int = 2) -> dict[str, float]:
"""문자 2-gram 빈도를 벡터로 표현한다 (실제 임베딩 모델은 의미를 학습한 벡터를 반환)"""
text = text.replace(" ", "")
ngrams = [text[i:i + n] for i in range(len(text) - n + 1)]
count = Counter(ngrams)
total = sum(count.values()) or 1
return {k: v / total for k, v in count.items()}
def cosine_similarity(vec_a: dict[str, float], vec_b: dict[str, float]) -> float:
"""두 희소 벡터의 코사인 유사도를 계산한다"""
common = set(vec_a) & set(vec_b)
dot = sum(vec_a[k] * vec_b[k] for k in common)
norm_a = math.sqrt(sum(v ** 2 for v in vec_a.values()))
norm_b = math.sqrt(sum(v ** 2 for v in vec_b.values()))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
class DenseRetriever:
"""간이 밀집검색기: 문자 n-gram 벡터 + 코사인 유사도"""
def __init__(self, documents: list[str]):
# 오프라인 단계: 모든 문서를 미리 벡터로 변환해 저장 (임베딩 인덱싱)
self.doc_vectors = [char_ngram_vector(doc) for doc in documents]
def score(self, query: str) -> list[float]:
"""온라인 단계: 쿼리만 벡터화해 저장된 벡터와 코사인 유사도 계산"""
query_vec = char_ngram_vector(query)
return [cosine_similarity(query_vec, dv) for dv in self.doc_vectors]
# ══════════════════════════════════════════════
# 3. 앙상블 검색 (희소 + 밀집 가중합)
# 실제로는 LangChain의 EnsembleRetriever(weights=[0.5, 0.5])를 사용한다
# ══════════════════════════════════════════════
def normalize(scores: list[float]) -> list[float]:
"""점수를 [0, 1] 범위로 정규화해 두 방식의 스케일을 맞춘다"""
max_s = max(scores) if scores else 1
min_s = min(scores) if scores else 0
span = max_s - min_s or 1
return [(s - min_s) / span for s in scores]
def ensemble_score(bm25_scores, dense_scores, w_bm25=0.5, w_dense=0.5):
"""
정규화된 두 점수를 가중합한다 (w_bm25 + w_dense = 1.0)
기술/법률 문서: w_bm25를 높임 / 일상 검색: w_dense를 높임
"""
norm_bm25 = normalize(bm25_scores)
norm_dense = normalize(dense_scores)
return [w_bm25 * b + w_dense * d for b, d in zip(norm_bm25, norm_dense)]
# ──────────────────────────────────────────────
# 검색 실행 및 결과 출력
# ──────────────────────────────────────────────
def print_results(label: str, scores: list[float], top_k: int = 3):
"""점수 내림차순으로 상위 k개 결과를 출력한다"""
ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)[:top_k]
print(f"\n[{label}] 상위 {top_k}개")
print("-" * 50)
for rank, (doc_id, score) in enumerate(ranked, 1):
print(f" {rank}위: 《{DOCS[doc_id]['title']}》 점수={score:.4f}")
def main():
corpus = [doc["text"] for doc in DOCS]
# 검색기 초기화 (오프라인 인덱싱 단계)
bm25 = SimpleBM25(corpus)
dense = DenseRetriever(corpus)
print("=" * 60)
print("요리 레시피 검색 비교: 희소검색 vs 밀집검색 vs 앙상블")
print("=" * 60)
# ── 쿼리 1: 키워드가 정확히 일치 → BM25(희소검색)가 유리 ──
query1 = "파스타 올리브오일 요리 추천"
print(f"\n▶ 쿼리 1 (키워드 정확 일치 유리): \"{query1}\"")
b1, d1 = bm25.score(query1), dense.score(query1)
print_results("희소검색 (BM25)", b1)
print_results("밀집검색 (코사인)", d1)
print_results("앙상블 (0.5 : 0.5)", ensemble_score(b1, d1))
# ── 쿼리 2: 키워드 불일치, 의미 유사 → 밀집검색이 유리 ──
query2 = "이탈리아 전통 오븐 요리"
print(f"\n▶ 쿼리 2 (의미 기반 검색 유리): \"{query2}\"")
b2, d2 = bm25.score(query2), dense.score(query2)
print_results("희소검색 (BM25)", b2)
print_results("밀집검색 (코사인)", d2)
print_results("앙상블 (0.5 : 0.5)", ensemble_score(b2, d2))
print("\n" + "=" * 60)
print("앙상블은 한쪽이 놓친 결과를 다른 쪽이 보완해 균형을 맞춘다.")
print("=" * 60)
if __name__ == "__main__":
main()
세 방식을 직접 돌려보면 '정답으로 정해진 하나의 검색법은 없다'는 게 분명해진다. 검색 대상이 고유명사·전문 용어 위주라면 키워드 쪽으로, 표현이 제각각인 일반 질문이라면 의미 쪽으로 무게를 옮기는 것 — 결국 검색 알고리즘 선택도 앞선 기법들과 똑같이, 내 데이터와 질문의 성격을 먼저 보고 저울질하는 일이다.
'AI Engineering > RAG' 카테고리의 다른 글
| RAG 성능 고도화 — 리랭킹: 검색 결과를 한 번 더 거른다 (0) | 2026.06.27 |
|---|---|
| RAG 성능 고도화 — HyDE, 가상의 답을 지어내 검색하기 (0) | 2026.06.22 |
| RAG 성능 고도화 — 질의 변형과 다중 질의 생성 (1) | 2026.06.21 |
| RAG 성능 고도화 — 청킹 전략 깊게 파기 (0) | 2026.06.21 |