- Published on
Pinecone·Milvus 하이브리드검색 튜닝 - BM25+임베딩
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서치 품질을 올리려다 보면 결국 마주치는 벽이 있습니다. 임베딩 벡터 검색은 의미 유사도에 강하지만, 제품명·에러코드·약어·정확 일치(Exact match)에는 약하고, 반대로 BM25 같은 키워드 기반은 의미 확장에 약합니다. 하이브리드 검색은 이 둘의 장점을 결합해 정확도(precision) 와 재현율(recall) 을 동시에 끌어올리는 가장 실용적인 접근입니다.
이 글은 Pinecone와 Milvus를 기준으로, BM25 + 임베딩 하이브리드 검색을 어떻게 설계하고 어떤 파라미터를 어떻게 튜닝해야 하는지, 그리고 운영에서 흔히 터지는 함정(점수 스케일, 필터, 리랭킹 비용)을 어떻게 피하는지까지 정리합니다.
관련해서 검색 결과를 LLM 에이전트에 연결할 때는 툴 폭주/무한루프를 막는 가드레일도 같이 고려해야 합니다. 필요하면 LangChain 에이전트 무한루프·툴폭주 차단법도 함께 참고하세요.
하이브리드 검색의 핵심: 점수 결합이 전부다
하이브리드 검색은 보통 아래 두 점수를 결합합니다.
score_dense: 임베딩 기반 유사도(코사인/내적/거리 기반)score_sparse: BM25(또는 TF-IDF 계열)의 키워드 점수
문제는 두 점수가 스케일도 다르고 분포도 다르다는 점입니다.
- BM25는 쿼리 길이, 문서 길이, IDF 등에 따라 점수 범위가 크게 튑니다.
- 임베딩 유사도는 보통
-1..1혹은0..1근처로 좁게 움직입니다(모델/정규화에 따라 다름).
따라서 단순히 alpha * dense + (1-alpha) * sparse를 하면, 한쪽이 항상 이겨버리거나 특정 쿼리에서만 폭주합니다. 튜닝의 80%는 정규화 + 가중치 + 후보군 전략 입니다.
아키텍처 3가지 패턴
1) 단일 엔진에서 하이브리드(가능한 경우)
- Pinecone: 인덱스에 sparse 벡터와 dense 벡터를 함께 넣고, 쿼리에서 둘을 같이 전달하는 방식이 일반적입니다.
- Milvus: 버전/구성에 따라 sparse와 dense를 같은 컬렉션에 두거나, 별도 컬렉션/외부 BM25(예: OpenSearch) 조합이 더 현실적일 수 있습니다.
장점: 네트워크 왕복이 적고 결합이 단순합니다. 단점: sparse 생성/업데이트 파이프라인이 까다롭고, 제공 기능 제약이 있습니다.
2) 듀얼 리트리버 + 서버에서 점수 결합(가장 흔함)
- BM25는 OpenSearch/Elasticsearch
- Dense는 Pinecone 또는 Milvus
- 애플리케이션에서 topK 후보를 받아 점수 결합 후 재정렬
장점: 각 엔진을 최적화해 쓸 수 있습니다. 단점: 정규화/지연시간/중복제거/필터 일관성을 직접 구현해야 합니다.
3) 후보군은 하이브리드, 최종은 리랭커(정답률 최강)
- 1차: 하이브리드로 후보
K=100..500확보 - 2차: Cross-encoder 리랭커(예: bge-reranker, Cohere rerank, 자체 모델)로 topN 확정
장점: 검색 품질이 확실히 올라갑니다. 단점: 비용/지연시간 증가. 캐시 전략이 중요합니다.
튜닝 체크리스트 1: 토크나이저/분석기부터 맞춰라
BM25는 토큰화/정규화가 품질을 좌우합니다.
- 한글: 형태소 분석(예: nori) vs n-gram
- 영문: lowercasing, stemming, stopwords
- 코드/에러:
HTTP_413,E0502같은 패턴은 분리 규칙이 중요
임베딩도 마찬가지로 전처리가 필요합니다.
- 제목+본문을 그대로 넣으면 노이즈가 커질 수 있음
- 코드 블록, 로그, 테이블을 어떤 비율로 포함할지 결정
- chunk 크기와 overlap이 검색 품질에 직접 영향
튜닝 체크리스트 2: 점수 정규화(스케일 문제 해결)
가장 무난한 방법은 쿼리 단위 정규화 입니다. 같은 쿼리에서 나온 topK 점수 분포를 이용해 정규화하면, 쿼리 길이/난이도에 따른 점수 변동을 줄일 수 있습니다.
쿼리 단위 min-max 정규화
norm = (s - min) / (max - min + eps)
쿼리 단위 z-score 정규화
norm = (s - mean) / (std + eps)
min-max는 직관적이고, z-score는 꼬리가 긴 분포에서 안정적입니다. BM25는 꼬리가 길어 z-score가 유리한 경우가 많습니다.
아래는 서버에서 BM25와 dense 결과를 받아 결합하는 예시입니다.
type Hit = { id: string; score: number; payload?: any };
function minMaxNormalize(hits: Hit[]) {
const scores = hits.map(h => h.score);
const min = Math.min(...scores);
const max = Math.max(...scores);
const eps = 1e-9;
return new Map(
hits.map(h => [h.id, (h.score - min) / (max - min + eps)])
);
}
function hybridFuse(params: {
dense: Hit[];
sparse: Hit[];
alpha: number; // 0..1, dense weight
}) {
const denseNorm = minMaxNormalize(params.dense);
const sparseNorm = minMaxNormalize(params.sparse);
const ids = new Set<string>([
...params.dense.map(h => h.id),
...params.sparse.map(h => h.id),
]);
const fused: Hit[] = [];
for (const id of ids) {
const d = denseNorm.get(id) ?? 0;
const s = sparseNorm.get(id) ?? 0;
const score = params.alpha * d + (1 - params.alpha) * s;
fused.push({ id, score });
}
fused.sort((a, b) => b.score - a.score);
return fused;
}
이 방식의 장점은 구현이 쉽다는 점입니다. 단, topK가 너무 작으면 정규화가 불안정 해집니다. 보통 각 리트리버에서 K=100 이상 받아서 결합하는 편이 튜닝이 쉽습니다.
튜닝 체크리스트 3: 가중치 alpha를 고정하지 말고 동적으로
alpha를 전역 상수로 두면, 쿼리 유형에 따라 품질이 크게 흔들립니다.
- 제품명/에러코드/정확한 문구: sparse 비중을 올려야 함
- 자연어 질문/의미 확장: dense 비중을 올려야 함
실무에서는 휴리스틱으로 동적 가중치를 많이 씁니다.
- 쿼리에 숫자/특수문자/대문자 토큰이 많으면 sparse 가중치 증가
- 쿼리 길이가 길수록 dense 가중치 증가
- OOV(사전에 없는 토큰) 비율이 높으면 dense 가중치 증가
function chooseAlpha(query: string) {
const hasDigits = /\d/.test(query);
const hasCodeLike = /[_:\/.-]/.test(query);
const len = query.trim().split(/\s+/).length;
// 기본은 dense 쪽을 조금 더
let alpha = 0.6;
if (hasDigits || hasCodeLike) alpha -= 0.2;
if (len >= 8) alpha += 0.1;
// 0.1..0.9로 클램프
alpha = Math.max(0.1, Math.min(0.9, alpha));
return alpha;
}
정교하게 하려면, 쿼리 분류기를 두고 alpha를 클래스별로 학습/튜닝하는 방식도 가능합니다.
Pinecone에서의 구현 포인트
Pinecone은 일반적으로 dense 벡터 검색에 강점이 있고, 하이브리드 구성에서는 sparse 벡터를 함께 사용합니다. 핵심은 다음입니다.
- 업서트 시 dense 벡터와 sparse 벡터를 함께 저장
- 쿼리 시 dense와 sparse를 같이 넣고, 가중치를 적용
- 메타데이터 필터는 하이브리드 점수 결합 이전에 적용되도록 설계
서버에서 sparse 벡터를 만들려면 보통 BM25 기반의 term -> weight 형태가 필요합니다. 토큰화/IDF 계산을 자체 구현하거나, 별도 검색엔진(OpenSearch)에서 sparse를 만들고 Pinecone에는 dense만 두는 하이브리드(듀얼 리트리버)도 흔합니다.
운영 팁:
- 필터가 강하면(예:
tenant_id,language,category) dense 후보군이 급감해 품질이 흔들릴 수 있으니, 필터 카디널리티를 반드시 측정하세요. topK를 너무 작게 두면 하이브리드가 의미가 없습니다. 1차 후보는 넉넉히, 최종 반환만 작게.
Milvus에서의 구현 포인트
Milvus는 벡터 검색 엔진으로서 인덱스/세그먼트/컴팩션 등 운영 요소가 품질과 지연시간에 영향을 줍니다. 하이브리드는 보통 아래 형태로 구성합니다.
- Dense: Milvus
- Sparse(BM25): OpenSearch/Elasticsearch 또는 별도 BM25 라이브러리
- 앱 서버에서 결합 + 중복 제거 + 리랭킹
Milvus 튜닝 포인트:
- IVF 계열:
nlist,nprobe튜닝으로 recall/latency 트레이드오프 조절 - HNSW:
M,efConstruction, 검색 시ef튜닝 - 쿼리 필터가 많으면 scalar index 구성(필드 인덱스)과 파티셔닝 전략이 중요
특히 하이브리드에서는 dense 쪽이 recall을 충분히 확보해야 합니다. BM25가 잡아주지 못하는 의미 매칭을 dense가 가져와야 하므로, dense 인덱스를 지나치게 공격적으로(너무 낮은 nprobe/ef) 설정하면 전체 품질이 급락합니다.
후보군 전략: OR로 넓게, 최종은 AND로 좁게
하이브리드에서 흔한 실수는 “둘 다 높은 문서만” 뽑으려는 것입니다. 그렇게 하면 정확 일치도 놓치고 의미 유사도도 놓칩니다.
권장 패턴:
- 후보군 생성은
dense topK와sparse topK를 합집합(OR) 으로 넓게 잡기 - 결합 점수로 재정렬
- 필요하면 리랭커로 topN 확정
이때 중복 제거는 doc_id 기준으로 하고, chunk 단위 검색이라면 doc_id로 묶어 대표 chunk를 올리거나, 문서 단위로 점수를 집계하는 방식이 안정적입니다.
문서 단위 집계 예시(최대값)
from collections import defaultdict
def aggregate_by_doc(hits):
by_doc = defaultdict(list)
for h in hits:
by_doc[h["doc_id"]].append(h["score"])
# 문서 대표 점수는 max 또는 top2 평균 등을 사용
return [
{"doc_id": doc_id, "score": max(scores)}
for doc_id, scores in by_doc.items()
]
리랭킹을 붙일 때: 비용을 통제하는 3가지 방법
리랭커는 품질을 가장 확실히 올리지만, 비용/지연시간이 문제입니다.
- 후보 K를 줄이지 말고, 리랭커 입력 N을 줄여라
- 예: 후보
K=200확보 후, 결합 점수 상위N=30만 리랭킹
- 예: 후보
- 쿼리-후보 캐시
- 동일 쿼리 반복이 많으면
query_hash기반 캐시가 잘 먹힙니다.
- 동일 쿼리 반복이 많으면
- 문서 단위로 리랭킹
- chunk 30개 대신 문서 10개로 줄이면 비용이 즉시 내려갑니다.
LLM API를 붙이는 경우 요청 크기 제한도 고려해야 합니다. 큰 문서를 그대로 넣다 413을 맞는 경우가 흔하니, 청크 전략은 OpenAI Responses API 413 에러 업로드 용량 제한과 청크 전략을 같이 참고하면 좋습니다.
온라인 튜닝: 오프라인 지표와 함께 봐야 한다
하이브리드 튜닝은 “좋아 보인다”가 아니라 지표로 해야 합니다.
- 오프라인: MRR, nDCG, Recall@K, Precision@K
- 온라인: CTR, reformulation rate(재검색 비율), dwell time, zero-result rate
권장 절차:
- 쿼리 로그에서 대표 쿼리 샘플링
- 정답(클릭/구매/채택) 기반으로 약한 레이블 생성
alpha, 정규화 방식, 각 topK, dense 인덱스 파라미터를 그리드/베이지안 탐색- 상위 2~3개 후보를 A/B 테스트
여기서 중요한 점은, 하이브리드가 좋아질수록 다운스트림(예: RAG 답변 생성, 에이전트 실행)이 더 많은 컨텍스트를 소비해 비용이 늘 수 있다는 것입니다. 검색 품질 튜닝은 항상 최종 시스템 비용 과 함께 최적화해야 합니다.
실무에서 자주 터지는 함정 7가지
- 점수 스케일 미정규화 로 한쪽이 항상 이김
- BM25 분석기 불일치(색인/검색 분석기가 다름)
- chunk 단위 검색 후 문서 단위 중복 제거를 안 해서 결과가 지저분해짐
- 필터가 강해져 dense recall이 급락
- topK가 너무 작아 하이브리드 결합이 의미 없음
- 리랭커 입력이 커져 지연시간/비용 폭발
- 운영 중 인덱스 파라미터 변경으로 분포가 바뀌었는데
alpha를 고정해 품질이 흔들림
추천 기본값(시작점)
시작점으로 아래 조합이 무난합니다.
- 후보군: dense
topK=150, sparsetopK=150 - 정규화: 쿼리 단위 min-max(간단) 또는 BM25만 z-score
- 가중치: 기본
alpha=0.6, 코드/숫자 포함 시alpha=0.4 - 리랭킹: 결합 상위
N=20..40만 적용
이후 도메인별로 달라지는 지점은 크게 두 가지입니다.
- BM25 분석기(토큰화)와 동의어/오타 교정
- 임베딩 모델 선택 및 chunk 정책
마무리
Pinecone/Milvus 하이브리드 검색의 본질은 “엔진 선택”이 아니라 점수 결합을 어떻게 안정적으로 만들고, 후보군을 어떻게 넓게 확보한 뒤, 리랭킹 비용을 어떻게 통제하느냐 입니다.
정규화와 동적 alpha만 제대로 잡아도 체감 품질이 크게 오르고, 여기에 문서 단위 집계와 제한된 리랭킹을 더하면 검색 경험이 한 단계 올라갑니다. 운영 단계에서는 쿼리 분포 변화에 따라 alpha와 인덱스 파라미터를 재튜닝할 수 있도록, 오프라인 평가 파이프라인을 꼭 만들어 두는 것을 권장합니다.