- Published on
Milvus·Pinecone 하이브리드검색 튜닝 - BM25+임베딩
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
검색 품질을 올리려다 보면 금방 한계를 만납니다. 임베딩 기반 벡터 검색은 의미 유사도에 강하지만, 제품명·버전·코드·약어처럼 정확한 토큰 매칭이 중요한 쿼리에서 약해질 수 있습니다. 반대로 BM25 같은 키워드 검색은 정밀 매칭에 강하지만 동의어·의미 확장에 약합니다.
그래서 실무에서는 두 신호를 결합한 **하이브리드 검색(BM25+임베딩)**이 사실상 표준이 됐습니다. 이 글에서는 Milvus와 Pinecone를 기준으로 하이브리드 검색을 설계하고, 실제로 품질을 끌어올리는 튜닝 포인트(가중치, 후보군 크기, 리랭킹, 필터, 지연시간)를 정리합니다.
또한 운영 중 성능 병목을 추적하는 관점도 함께 다룹니다. 데이터베이스/시스템 레벨에서 원인을 좁히는 방법은 PostgreSQL 쿼리 느림? auto_explain으로 추적, OS 메모리 압박은 리눅스 OOM Killer로 프로세스 죽음 원인 추적도 참고하면 좋습니다.
하이브리드 검색의 3가지 결합 방식
하이브리드 검색은 “BM25 결과와 벡터 결과를 섞는다”로 끝나지 않습니다. 결합 지점에 따라 튜닝 포인트와 비용이 크게 달라집니다.
1) Late fusion: 두 결과를 점수로 합산
- BM25로
topK_bm25, 벡터로topK_vec를 각각 구함 - 문서 ID 기준으로 합집합을 만들고, 점수를 정규화한 뒤 가중합
장점: 구현이 쉽고, Milvus/Pinecone 외부에서 제어 가능 단점: 두 검색을 모두 수행하므로 비용과 지연시간이 증가할 수 있음
2) Candidate generation + rerank
- 1차 후보군을 BM25 또는 벡터로 크게 뽑고
- 2차에서 다른 신호(예: BM25, 크로스 인코더, 규칙)를 적용해 재정렬
장점: 비용 대비 효과가 좋고, “정확도” 튜닝이 쉬움 단점: 후보군이 잘못 뽑히면 리랭킹이 아무리 좋아도 복구 불가
3) Single-stage hybrid index(엔진 기능 활용)
- 일부 엔진은 sparse+dense를 한 번에 다루는 기능을 제공
장점: 운영 단순화, latency 최적화 가능 단점: 벤더/버전 종속, 점수 해석과 디버깅이 어려울 수 있음
이 글에서는 late fusion과 candidate+rerank를 중심으로, Milvus/Pinecone에서 현실적으로 적용 가능한 튜닝을 다룹니다.
점수 결합의 핵심: 정규화 없이는 가중치가 의미 없다
BM25 점수와 코사인 유사도(또는 내적)는 스케일이 다릅니다. 스케일이 다른 점수를 그대로 더하면, 가중치 alpha를 조정해도 결과가 안정적으로 움직이지 않습니다.
실무에서 가장 흔한 정규화는 아래 두 가지입니다.
- Min-Max 정규화: 상위 후보 집합 내에서
0..1로 맞추기 - Rank 기반 점수: 순위에 따라
1/(k+rank)같은 점수 부여
Min-Max는 점수 분포가 쿼리마다 크게 흔들리면 불안정할 수 있고, Rank 기반은 스케일 문제는 해결하지만 “점수의 의미”가 사라집니다. 운영 환경에서는 둘 다 시도해보고, 쿼리군별로 안정적인 쪽을 선택하는 경우가 많습니다.
예시: Late fusion 점수 합산 코드
아래는 BM25 결과와 벡터 결과를 합쳐서 최종 점수를 만드는 단순 예시입니다.
from dataclasses import dataclass
from typing import Dict, List, Tuple
@dataclass
class Hit:
doc_id: str
score: float
def minmax_norm(scores: Dict[str, float]) -> Dict[str, float]:
if not scores:
return {}
vals = list(scores.values())
mn, mx = min(vals), max(vals)
if mx == mn:
return {k: 1.0 for k in scores}
return {k: (v - mn) / (mx - mn) for k, v in scores.items()}
def hybrid_fuse(
bm25_hits: List[Hit],
vec_hits: List[Hit],
alpha: float = 0.5,
) -> List[Tuple[str, float]]:
bm25 = {h.doc_id: h.score for h in bm25_hits}
vec = {h.doc_id: h.score for h in vec_hits}
bm25_n = minmax_norm(bm25)
vec_n = minmax_norm(vec)
doc_ids = set(bm25_n.keys()) | set(vec_n.keys())
fused = []
for doc_id in doc_ids:
s_b = bm25_n.get(doc_id, 0.0)
s_v = vec_n.get(doc_id, 0.0)
fused.append((doc_id, alpha * s_b + (1 - alpha) * s_v))
fused.sort(key=lambda x: x[1], reverse=True)
return fused
튜닝 포인트는 alpha 하나로 보이지만, 실제로는 아래가 더 중요합니다.
topK_bm25,topK_vec를 얼마나 크게 뽑을지- 정규화를 어떤 방식으로 할지
- 필터/스코프(테넌트, 언어, 카테고리)를 어디에서 적용할지
Milvus에서의 하이브리드 설계 포인트
Milvus는 벡터 검색이 주력이고, BM25는 보통 외부(예: OpenSearch/Elasticsearch, PostgreSQL tsvector)와 조합하거나, sparse 벡터를 함께 쓰는 방식으로 하이브리드를 구현합니다. 실무에서는 다음 두 패턴이 많습니다.
패턴 A: Milvus(벡터) + 별도 BM25 엔진
- BM25는 OpenSearch/Elasticsearch가 담당
- Milvus는 dense 벡터 topK 담당
- 애플리케이션에서 late fusion 또는 candidate+rerank
장점: 각각의 강점을 극대화 단점: 인덱스 이중화, 동기화 비용 증가
패턴 B: Milvus에 dense+sparse를 같이 저장
- 문서를 dense 임베딩과 sparse(토큰 가중치) 벡터로 표현
- sparse는 BM25 그 자체는 아니지만 “키워드 신호”를 근사
장점: 단일 스토어로 단순화 단점: sparse 벡터 생성 파이프라인이 필요, BM25와 점수 해석이 다름
Milvus에서 성능 튜닝 체크리스트
nprobe(IVF 계열) 또는 HNSW 파라미터 조정으로 recall/latency 균형topK를 무작정 키우지 말고, 2단계 리랭킹으로 분리- 필터가 많다면 “필터 후 벡터 검색”이 가능한지 확인(엔진/버전에 따라 다름)
- 세그먼트/샤드 구성, 인덱스 빌드 시점, compaction 정책 점검
Milvus는 시스템 리소스(메모리, 파일 캐시, 디스크 IO)에 따라 지연시간 편차가 커질 수 있습니다. 특히 대규모 컬렉션에서 인덱스 메모리 압박이 심하면 OOM이나 스왑으로 이어질 수 있어, 문제가 생겼을 때는 리눅스 OOM Killer로 프로세스 죽음 원인 추적처럼 OS 레벨 로그까지 함께 보는 습관이 중요합니다.
Pinecone에서의 하이브리드 설계 포인트
Pinecone는 환경/플랜에 따라 sparse+dense 하이브리드를 지원하는 구성이 있고, 메타데이터 필터도 강점입니다. 다만 “BM25 그대로”를 넣는 방식보다는, sparse 벡터(토큰 가중치)를 사용해 키워드 신호를 결합하는 접근이 일반적입니다.
Pinecone 하이브리드의 핵심 튜닝 축
- sparse 벡터 품질: 토큰화, 불용어, 숫자/기호 처리, n-gram 여부
- dense 임베딩 품질: 도메인 적합한 모델, chunk 전략
alpha또는 sparse/dense 비중: 쿼리 유형별로 다르게 적용할지- 필터 선택도: 필터가 강하면 후보군이 줄어 recall이 떨어질 수 있음
예시: Pinecone 쿼리 시 가중치 적용(개념 코드)
SDK 버전에 따라 인터페이스가 다를 수 있으니, 아래는 “구조”를 보여주는 개념 예시입니다.
# 개념 예시: dense + sparse를 함께 질의하고 가중치를 조절
# 실제 필드명/파라미터는 사용 중인 Pinecone SDK 문서를 확인하세요.
query = {
"vector": dense_query_vector,
"sparse_vector": {
"indices": sparse_indices,
"values": sparse_values,
},
"top_k": 50,
"filter": {
"tenant_id": "t-123",
"lang": "ko",
},
"hybrid_alpha": 0.35,
}
res = index.query(**query)
여기서 hybrid_alpha 같은 값은 “정답”이 없습니다. 대신 쿼리군을 나눠서 다른 값을 쓰는 전략이 효과적입니다.
- SKU/모델명/버전 쿼리: sparse 비중을 높임
- 자연어 질문/설명형: dense 비중을 높임
- 짧은 1~2 토큰 쿼리: BM25/sparse가 대체로 안정적
튜닝 1: 쿼리 분류로 가중치를 다르게 적용
단일 alpha로 모든 쿼리를 커버하려 하면, 어떤 쿼리군에서는 반드시 손해를 봅니다. 가장 비용 대비 효과가 좋은 방법은 “가벼운 규칙 기반 쿼리 분류”입니다.
간단한 쿼리 분류 규칙 예시
- 숫자+하이픈 패턴(예:
AB-1234)이 있으면 키워드 우선 - 토큰 수가 2 이하이면 키워드 우선
- 물음표/조사/서술형 패턴이면 임베딩 우선
import re
def choose_alpha(q: str) -> float:
q = q.strip()
if re.search(r"[A-Za-z]{1,5}-\d{2,}", q):
return 0.75 # BM25/sparse 비중 높임
if len(q.split()) <= 2:
return 0.65
if q.endswith("?") or any(x in q for x in ["어떻게", "왜", "무엇", "차이"]):
return 0.25 # dense 비중 높임
return 0.45
이 정도만 해도 “정확 일치가 중요한 쿼리”에서 하이브리드가 흔들리는 문제가 크게 줄어듭니다.
튜닝 2: 후보군 크기(topK)는 리랭킹 전제를 두고 설계
하이브리드에서 자주 하는 실수는 topK를 크게 올려 recall을 확보하려다 latency와 비용을 폭발시키는 것입니다. 대신 아래처럼 단계화하는 편이 안정적입니다.
- 1차: BM25 또는 벡터로
topK=100~500후보 생성 - 2차: 합집합
N=200~800에서 가벼운 점수 결합 - 3차: 최종 상위
10~50만 크로스 인코더 리랭킹
특히 크로스 인코더는 비용이 크므로 “최종 몇 개만” 적용하는 게 중요합니다.
튜닝 3: 크로스 인코더 리랭킹으로 마지막 10점을 만든다
BM25+임베딩의 결합은 후보군을 잘 모으는 데 강하지만, 최상위 랭킹에서 “문장 단위 정합성”이 부족할 수 있습니다. 이때 크로스 인코더(또는 LLM 기반 rerank)를 붙이면 상위 품질이 확 뛰는 경우가 많습니다.
예시: 리랭킹 파이프라인(의사 코드)
# 1) 벡터 topK
vec_hits = milvus_search(query_vec, top_k=200)
# 2) BM25 topK
bm25_hits = bm25_search(query_text, top_k=200)
# 3) 결합
candidates = hybrid_fuse(bm25_hits, vec_hits, alpha=choose_alpha(query_text))
# 4) 상위 N만 리랭킹
top_docs = fetch_docs([doc_id for doc_id, _ in candidates[:50]])
reranked = cross_encoder_rerank(query_text, top_docs)
return reranked[:10]
리랭킹을 붙이면 alpha 튜닝의 민감도가 줄어들고, 운영 중 품질 변동도 완화됩니다.
튜닝 4: chunk 전략이 하이브리드 품질을 좌우한다
문서를 chunk로 쪼개 벡터화한다면, BM25는 “문서 단위”, 벡터는 “chunk 단위”가 되어 스코어 결합이 꼬일 수 있습니다. 이때 선택지는 크게 두 가지입니다.
- 둘 다 chunk 단위로 통일(권장): BM25도 chunk 텍스트로 인덱싱
- 벡터는 chunk, BM25는 문서: 결합 시 문서 ID로 roll-up 필요
roll-up을 한다면 아래 같은 규칙을 명확히 해야 합니다.
- 문서 점수 = chunk 점수의
max또는top2 평균 - 문서 대표 snippet 생성(하이라이트)
잘못하면 “문서 전체는 관련 없는데 chunk 하나가 걸려서 상위 노출” 같은 문제가 생깁니다.
튜닝 5: 필터는 검색 전/후 어디에 걸 것인가
멀티테넌트, 카테고리, 언어, 권한 같은 필터는 하이브리드에서 특히 중요합니다.
- 검색 전 필터: 불필요한 후보를 줄여 효율적이지만, 필터 선택도가 높으면 recall이 떨어질 수 있음
- 검색 후 필터: recall은 좋지만, 필터링으로 상위가 비어 재검색이 필요할 수 있음
실무 팁:
- 강제 조건(권한, 테넌트)은 무조건 검색 전
- 약한 조건(선호 카테고리)은 검색 후 가중치로 반영
운영 관점: 하이브리드는 “느려질 이유”가 많다
하이브리드는 구성 요소가 늘어나기 때문에, 느려졌을 때 원인 추적도 체계가 필요합니다.
병목을 나누는 관찰 지표
- BM25 엔진 latency (p50/p95)
- 벡터 DB latency (p50/p95)
- 결합/리랭킹 CPU 시간
- 외부 스토리지 fetch 시간(문서 본문 로딩)
- 캐시 히트율(쿼리 캐시, 임베딩 캐시)
BM25를 PostgreSQL로 구현했다면, 쿼리 플랜과 실제 실행 시간을 붙잡는 게 먼저입니다. 이때는 PostgreSQL 쿼리 느림? auto_explain으로 추적처럼 “느린 쿼리를 재현하지 않아도” 원인을 남기는 장치가 큰 도움이 됩니다.
또한 임베딩/리랭킹 워커가 갑자기 죽거나 재시작 루프를 돈다면, 애플리케이션 로그만 보지 말고 시스템 이벤트를 같이 봐야 합니다. 특히 메모리 급증이 있으면 OOM으로 프로세스가 종료될 수 있으니 리눅스 OOM Killer로 프로세스 죽음 원인 추적 흐름대로 확인하면 시간을 아낄 수 있습니다.
추천 튜닝 순서(실전)
- 평가셋부터 만든다: 최소 100~500개 쿼리와 정답 문서(또는 클릭 로그)를 준비
- chunk/토큰화 정리: chunk 단위 통일, 숫자/기호 처리, 동의어 규칙
- 후보군 전략 결정: late fusion 또는 candidate+rerank
- 정규화 방식 선택: min-max vs rank 기반, 쿼리군별 안정성 비교
- 쿼리 분류로
alpha분기: 짧은 쿼리/코드성 쿼리 우대 - 리랭킹 도입: 상위 20~50개만 크로스 인코더
- 성능 최적화: topK, 필터 위치, 캐시, 병렬화
마무리
Milvus/Pinecone에서 BM25+임베딩 하이브리드 검색을 잘 튜닝하려면, 단순히 두 점수를 더하는 것이 아니라 정규화, 후보군 크기, 쿼리 분류, chunk 단위, 필터 전략, 리랭킹을 함께 설계해야 합니다.
가장 빠른 성공 루트는 “후보군을 넉넉히 모으고(하지만 과하지 않게), 최종 랭킹은 리랭킹으로 결정하며, 쿼리군별로 가중치를 다르게 주는 것”입니다. 이 세 가지만 제대로 잡아도 하이브리드 검색 품질은 눈에 띄게 안정화됩니다.