- Published on
RAG 환각 잡는 하이브리드 검색 튜닝 - BM25+벡터
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 환각을 줄이려면 프롬프트보다 먼저 검색(retrieval) 품질을 올려야 합니다. 모델이 그럴듯하게 꾸며내는 상황의 상당수는, 애초에 정답 근거가 컨텍스트에 없거나(recall 실패) 엉뚱한 문서가 상위에 올라와(precision 실패) 답변이 왜곡되면서 발생합니다.
이 글은 BM25(키워드)와 벡터(의미) 검색을 결합한 하이브리드 검색을 어떻게 튜닝해야 환각을 실질적으로 줄일 수 있는지, 특히 BM25+벡터 점수 결합과 운영 파라미터를 중심으로 다룹니다.
관련해서 재랭커까지 포함한 전체 파이프라인은 아래 글에서 더 확장된 관점으로 정리해두었습니다.
왜 BM25+벡터가 환각을 줄이나
벡터 단독의 함정: 의미는 맞는데 사실이 틀린 문서
벡터 검색은 “비슷한 이야기”를 잘 찾습니다. 문제는 RAG에서 필요한 건 “비슷한 이야기”가 아니라 질문에 대한 근거가 있는 문장입니다.
예를 들어 제품 정책, API 스펙, 규정 문서처럼 정확한 키워드와 수치가 중요한 도메인에서는 벡터가 다음을 자주 놓칩니다.
- 에러 코드, 파라미터 이름, 버전 문자열 같은 희소 토큰
- 부정 표현(예: “지원하지 않음”) 같은 결정적 키워드
- 유사 제품/유사 정책 문서로의 의미적 오탐
BM25 단독의 함정: 표현이 다르면 못 찾는다
BM25는 정확한 토큰 매칭에 강하지만, 사용자가 다르게 표현하면 recall이 급감합니다.
- 동의어, 약어, 번역어
- 문장 구조 변화
- 질문이 길고 잡음이 많은 경우
결론: 환각을 줄이는 방향의 역할 분담
- BM25는 정확한 키워드 근거를 끌어올려 precision을 보강
- 벡터는 표현 다양성을 흡수해 recall을 보강
- 둘을 결합하면 “근거가 있는 문서가 상위에 남을 확률”이 올라가고, 결과적으로 모델이 꾸며낼 여지가 줄어듭니다.
하이브리드 검색의 3가지 결합 방식
현업에서 가장 많이 쓰는 결합은 다음 3가지입니다.
1) 후보 합집합 후 재랭크(추천)
- BM25 상위
k1개 + 벡터 상위k2개를 합쳐 후보를 만들고 - 후보에 대해 점수 결합 또는 재랭킹
장점: recall이 높고, 뒤 단계에서 정밀하게 정리 가능
2) 점수 선형 결합(Weighted Sum)
같은 문서에 대해 BM25 점수와 벡터 점수를 정규화한 뒤
score = w * bm25 + (1 - w) * vector
장점: 구현이 단순 단점: 점수 스케일 정규화가 부실하면 튜닝이 망가짐
3) Reciprocal Rank Fusion(RRF)
점수 대신 순위로 결합합니다.
rrf = sum(1 / (k + rank_i))
장점: 스케일 정규화 걱정이 적고 안정적 단점: “BM25 점수가 압도적으로 높다” 같은 강한 신호를 활용하기 어렵다
실무 팁: 초기에는 RRF로 안정적인 베이스라인을 만들고, 이후에 선형 결합으로 세밀 튜닝하는 흐름이 실패가 적습니다.
튜닝의 핵심: 상위 문서가 아니라 “근거 스팬”을 올려라
RAG 품질을 올릴 때 흔히 top_k만 만집니다. 하지만 환각을 줄이려면 목표를 바꿔야 합니다.
- 목표: 상위
k문서 중 최소 1개에 **정답 근거 문장(스팬)**이 포함될 확률을 올리기
이를 위해 검색 튜닝은 다음 4가지 축으로 접근하는 게 효율적입니다.
- 쿼리 전처리(키워드 신호 강화)
- 후보 생성 폭(Recall) 확보
- 점수 결합 방식과 가중치 튜닝
- 청크 전략(문서 분할)과 메타데이터 필터
1) BM25 쿼리 튜닝: “희소 토큰”을 살려라
BM25는 희소 토큰에서 승부가 납니다. RAG에서 희소 토큰은 보통 다음입니다.
- 함수명, 클래스명, 파라미터명
- 에러 코드, HTTP 상태 코드
- 제품 SKU, 버전 문자열
- 날짜, 수치, 한정어(예: “최대”, “이상”, “미만”)
쿼리 정규화 체크리스트
- 대소문자, 하이픈, 언더스코어 변형 통일
- 숫자와 단위 분리(예:
10MB를10+MB) - 코드 토큰은 가능한 한 원형 유지
예시: 쿼리 토큰 강화(파이썬)
import re
STOP = {"the","a","an","is","are","to","of","and"}
def normalize_query(q: str) -> str:
q = q.strip()
# 코드/버전 토큰 보존을 위해 기본적인 정규화만 수행
q = re.sub(r"\s+", " ", q)
return q
def extract_sparse_hints(q: str) -> list[str]:
# 에러코드, 버전, snake/camel 토큰 등 희소 힌트 추출
hints = []
hints += re.findall(r"\b\d{3}\b", q) # 3자리 코드
hints += re.findall(r"\bv\d+(?:\.\d+)+\b", q) # v1.2.3
hints += re.findall(r"\b[A-Za-z_]+\b", q)
hints = [h for h in hints if h.lower() not in STOP]
return list(dict.fromkeys(hints))
q = "Why do I get 413 on ALB Ingress with NGINX ok? v1.27"
print(normalize_query(q))
print(extract_sparse_hints(q))
이 힌트를 BM25 검색 시 부스팅하거나, 최소한 쿼리에서 제거되지 않게 유지하는 것만으로도 “근거 문서가 아예 안 뜨는” 케이스가 줄어듭니다.
2) 후보 생성 폭 튜닝: k를 줄이면 환각이 늘 수 있다
하이브리드에서 흔한 실수는 “응답 속도” 때문에 top_k를 너무 줄이는 것입니다.
- 벡터
top_k=5 - BM25
top_k=5 - 합집합 후보가 실제로는 중복이 많아 6~8개 수준
이러면 질문이 조금만 길거나, 문서가 길고 청크가 많을 때 정답 근거가 후보에 못 들어올 확률이 급격히 상승합니다.
실전 가이드(출발점):
- BM25 후보
k1: 30~100 - 벡터 후보
k2: 30~100 - 합집합 후보 상한: 100~200
- 최종 컨텍스트로 넣을 청크 수: 4~12(모델 컨텍스트 길이에 따라)
성능은 재랭커나 결합 점수로 잡고, recall은 후보 폭으로 잡는 편이 안정적입니다.
3) 점수 결합 튜닝: 정규화가 90퍼센트다
BM25 점수는 인덱스/필드/문서 길이에 따라 분포가 크게 달라지고, 벡터 유사도는 보통 -1부터 1 또는 0부터 1에 있습니다. 두 점수를 그냥 더하면 가중치 튜닝이 의미가 없어집니다.
추천 정규화 2가지
(A) 쿼리별 min-max 정규화
각 쿼리에서 후보 집합에 대해 정규화합니다.
- 장점: 구현 쉬움, 쿼리 난이도 변화에 적응
- 단점: 후보 집합이 작으면 불안정
def minmax(xs):
mn, mx = min(xs), max(xs)
if mx - mn < 1e-9:
return [0.0 for _ in xs]
return [(x - mn) / (mx - mn) for x in xs]
def hybrid_scores(bm25_scores, vec_scores, w=0.5):
b = minmax(bm25_scores)
v = minmax(vec_scores)
return [w*bb + (1-w)*vv for bb, vv in zip(b, v)]
(B) z-score 후 시그모이드(좀 더 안정적)
분포가 찌그러진 점수에 강합니다.
import math
def zsig(xs):
mu = sum(xs)/len(xs)
var = sum((x-mu)**2 for x in xs)/len(xs)
sd = math.sqrt(var) if var > 1e-9 else 1.0
zs = [(x-mu)/sd for x in xs]
return [1/(1+math.exp(-z)) for z in zs]
def hybrid_scores_zsig(bm25_scores, vec_scores, w=0.6):
b = zsig(bm25_scores)
v = zsig(vec_scores)
return [w*bb + (1-w)*vv for bb, vv in zip(b, v)]
가중치 w를 어떻게 잡나
정답이 “정확한 키워드”에 의존하는 도메인일수록 BM25 비중을 올립니다.
- 정책/규정/스펙/에러코드 중심:
w=0.6~0.8 - FAQ/가이드/설명형 문서 중심:
w=0.4~0.6
팁: 환각 이슈가 “그럴듯한데 틀린 답”이라면 BM25 비중을 올리는 방향이 효과적인 경우가 많습니다. 벡터가 유사 문서를 강하게 끌어올리고 있을 가능성이 큽니다.
4) 청크 전략이 검색을 망치거나 살린다
하이브리드 검색은 결국 “청크”를 가져옵니다. 청크가 잘못 쪼개지면 BM25도 벡터도 근거를 못 찾습니다.
환각을 부르는 청크 패턴
- 한 청크에 주제가 너무 많아 벡터가 분산됨
- 문장 중간 분할로 핵심 정의가 끊김
- 표/리스트가 깨져서 BM25 토큰이 사라짐
추천 전략
- 단락 기반 분할 + 최대 토큰 제한
- 오버랩(예: 10~20퍼센트)로 정의 문장 누락 방지
- 표/코드 블록은 가능한 한 통째로 유지
예시: 단락 기반 청크(의사 코드)
def chunk_paragraphs(paragraphs, max_tokens=350, overlap_tokens=60):
chunks = []
cur, cur_t = [], 0
for p in paragraphs:
t = estimate_tokens(p)
if cur_t + t > max_tokens and cur:
chunks.append("\n\n".join(cur))
# overlap: 마지막 일부를 다음 청크로
cur = [tail_tokens(chunks[-1], overlap_tokens)]
cur_t = estimate_tokens(cur[0])
cur.append(p)
cur_t += t
if cur:
chunks.append("\n\n".join(cur))
return chunks
하이브리드 검색을 “환각 방지 장치”로 만드는 운영 체크리스트
1) 메타데이터 필터를 먼저 적용
가능하면 검색 전에 범위를 줄이세요.
- 제품명, 버전, 언어, 문서 타입(스펙/가이드/릴리즈노트)
- 테넌트, 권한, 조직
필터가 없으면 벡터는 특히 “비슷한데 다른 제품” 문서를 올려 환각을 유도합니다.
2) 중복 제거와 다양성 확보
합집합 후보에서 같은 문서의 인접 청크가 상위를 도배하면, 모델이 한 관점으로 과적합됩니다.
- 문서 단위로
max_per_doc제한 - MMR(Maximal Marginal Relevance)로 다양성 확보
3) 컨텍스트에 “근거 우선” 포맷을 강제
검색이 좋아도 LLM이 근거를 무시하면 환각이 납니다.
- 청크에
source,section,updated_at메타를 붙여 신뢰 신호 제공 - 답변 템플릿에서 “근거 인용 후 결론” 순서를 강제
구조화 출력 관련 이슈(스키마 에러, 400 등)를 겪는다면 아래 글이 도움이 됩니다.
실전 예시: BM25+벡터 하이브리드 검색 파이프라인
아래는 “BM25 후보”와 “벡터 후보”를 합친 뒤, RRF로 결합하고, 마지막에 상위 청크를 뽑는 전형적인 형태입니다. 검색 엔진은 Elasticsearch, OpenSearch, Meilisearch, Vespa 등으로 바꿔도 구조는 동일합니다.
from collections import defaultdict
def rrf_fusion(rank_lists, k=60):
# rank_lists: {"bm25": [doc_id1, doc_id2, ...], "vec": [...]} 형태
scores = defaultdict(float)
for _, docs in rank_lists.items():
for r, doc_id in enumerate(docs, start=1):
scores[doc_id] += 1.0 / (k + r)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
def hybrid_retrieve(query):
bm25_docs = bm25_search(query, top_k=80) # [(doc_id, score), ...]
vec_docs = vector_search(query, top_k=80)
bm25_ids = [d for d, _ in bm25_docs]
vec_ids = [d for d, _ in vec_docs]
fused = rrf_fusion({"bm25": bm25_ids, "vec": vec_ids}, k=60)
# 문서당 청크 제한 등 후처리
final = []
per_doc = defaultdict(int)
for doc_id, _ in fused:
if per_doc[get_parent_doc(doc_id)] >= 2:
continue
final.append(doc_id)
per_doc[get_parent_doc(doc_id)] += 1
if len(final) >= 8:
break
return fetch_chunks(final)
이 방식의 장점은 다음입니다.
- 점수 스케일 정규화가 필요 없어 초기 안정성이 높음
- BM25와 벡터 중 하나가 흔들려도 전체가 급격히 망가지지 않음
평가 방법: 검색 튜닝은 오프라인 지표가 필수
환각은 “생성 결과”로만 보면 원인 분리가 어렵습니다. 검색 튜닝은 최소한 아래를 오프라인으로 측정해야 합니다.
- Recall@K: 정답 근거가 상위 K 후보에 포함되는가
- Precision@K: 상위 K에 불필요 문서가 얼마나 섞이는가
- MRR: 정답 근거가 얼마나 위에 뜨는가
데이터셋을 만들 때는 “정답 문서”가 아니라 가능하면 “정답 근거 청크”를 라벨링하세요. 문서 단위 라벨은 청크 분할이 바뀌는 순간 무너집니다.
자주 터지는 문제와 처방
문제 1) 벡터가 너무 강해서 비슷한 문서만 뜬다
- 처방: BM25 가중치 상승, 메타 필터 강화, 후보 폭 확대 후 다양성 제약
문제 2) BM25가 강해서 키워드만 맞는 문서가 뜬다
- 처방: 벡터 후보
k2확대, 동의어 사전, 쿼리 리라이트(질문을 짧게 핵심화)
문제 3) 답변이 길어질수록 환각이 늘어난다
처방: 컨텍스트 청크 수를 늘리기보다, 상위 청크의 “근거 밀도”를 올리고 인용 기반 템플릿을 강제. 에이전트 사용 시 무한 루프나 토큰 폭탄도 함께 점검
결론: 하이브리드는 “검색을 보수적으로” 만들어 환각을 줄인다
BM25+벡터 하이브리드 검색 튜닝의 본질은, 의미 유사도에만 기대지 않고 키워드 근거를 보수적으로 확보해 모델이 상상할 공간을 줄이는 것입니다.
실전 적용 순서를 요약하면 아래가 가장 안전합니다.
- BM25
k1와 벡터k2를 충분히 크게 잡아 후보 recall 확보 - RRF로 안정적인 결합 베이스라인 구축
- 메타데이터 필터, 문서당 청크 제한, MMR로 오탐과 편향 감소
- 필요 시 점수 정규화 후 선형 결합으로 가중치 미세 튜닝
- 오프라인 지표(Recall@K, MRR)로 튜닝을 수치화
이 흐름으로 가면 “그럴듯한데 틀린 답”이 눈에 띄게 줄고, RAG가 실제 서비스에서 신뢰를 얻기 시작합니다.