- Published on
Pinecone·Milvus 하이브리드검색 튜닝 - RRF+MMR
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
왜 하이브리드 검색이 ‘튜닝 게임’이 되는가
RAG나 사내 문서 검색을 운영해보면, 단순 벡터 검색만으로는 사용자가 기대하는 결과가 흔들립니다. 이유는 명확합니다.
- 벡터 검색은 의미 유사도에 강하지만 고유명사, 코드/에러 메시지, 약어, 버전 문자열처럼 정확 일치가 중요한 질의에서 약해집니다.
- 키워드(BM25 등)는 정확 일치에 강하지만 동의어/패러프레이즈, 문장형 질의에서 누락이 생깁니다.
그래서 Pinecone·Milvus 모두 “하이브리드(lexical+vector)”를 구성하게 되는데, 이때부터 문제가 시작됩니다.
- 두 랭커의 점수 스케일이 다르고(코사인 유사도 vs BM25), 2) 문서 길이/용어 분포에 따라 점수 분산이 달라지며, 3) 상위 결과가 ‘비슷한 문서로만’ 몰리는 현상이 자주 발생합니다.
이 글은 그 문제를 RRF(Reciprocal Rank Fusion) 로 합치고, MMR(Maximal Marginal Relevance) 로 다양성을 제어하는 방식으로 정리합니다.
Pinecone·Milvus에서의 하이브리드 구성 관점
Pinecone
Pinecone은 인덱스/네임스페이스 구조와 메타데이터 필터가 강점이고, 하이브리드를 구성할 때 보통 아래 중 하나로 갑니다.
- 서로 다른 리트리버 2개(키워드용 외부 검색엔진 + Pinecone 벡터) 결과를 애플리케이션에서 합치기
- Pinecone의 기능(환경/플랜에 따라 다름)으로 sparse+dense를 함께 쓰되, 운영에서는 결국 후처리 재랭킹이 필요해지는 경우가 많음
핵심은 “어디서 검색하든” 최종 합치는 로직을 애플리케이션에서 통제할 수 있어야 튜닝이 가능합니다.
Milvus
Milvus는 벡터 DB로서 강력하고, 키워드 검색은 보통
- 외부(Elasticsearch/OpenSearch)와 병행하거나
- 텍스트 필드를 이용한 제한적 필터링 + 벡터 검색 중심
으로 구성됩니다. 즉, Milvus에서도 실전 하이브리드는 대개 2개의 결과 리스트를 애플리케이션에서 합치는 형태가 됩니다.
정리하면 Pinecone·Milvus 모두, “하이브리드 튜닝”은 DB 내부 옵션만으로 끝나지 않고 랭킹 합성 + 다양성 제어가 승부처입니다.
1단계: RRF로 ‘안정적으로’ 합치기
왜 RRF인가
하이브리드에서 가장 흔한 실수는 점수를 정규화해서 가중합 하는 것입니다.
- BM25는 쿼리 길이, 코퍼스 통계에 따라 분산이 크게 달라집니다.
- 벡터 유사도도 임베딩 모델, 정규화 여부에 따라 스케일이 달라집니다.
반면 RRF는 점수 자체를 믿지 않고 순위만 사용합니다. 그래서 데이터 분포가 바뀌거나 인덱스가 커져도 상대적으로 안정적입니다.
RRF는 다음처럼 정의합니다.
- 각 리트리버 결과에서 문서
d의 순위를rank_i(d)라고 할 때 score(d) = sum_i 1 / (k + rank_i(d))
여기서 k는 상위권 편향을 완화하는 상수입니다.
RRF 구현 예시(TypeScript)
아래 코드는 키워드 결과와 벡터 결과를 받아 RRF로 합칩니다. MDX 빌드 에러를 피하기 위해 제네릭 같은 표기는 모두 백틱으로 감쌉니다.
type SearchHit = {
id: string;
// 원 점수는 참고용으로만 보관
score?: number;
payload?: Record<string, unknown>;
};
type RankedList = SearchHit[]; // 이미 상위부터 정렬되어 있다고 가정
export function rrfFuse(
lists: RankedList[],
options?: { k?: number; weights?: number[] }
): Array<SearchHit & { rrf: number }> {
const k = options?.k ?? 60;
const weights = options?.weights ?? lists.map(() => 1);
const map = new Map<string, { hit: SearchHit; rrf: number }>();
lists.forEach((list, i) => {
const w = weights[i] ?? 1;
for (let idx = 0; idx < list.length; idx++) {
const hit = list[idx];
const rank = idx + 1;
const add = w * (1 / (k + rank));
const prev = map.get(hit.id);
if (prev) {
prev.rrf += add;
} else {
map.set(hit.id, { hit, rrf: add });
}
}
});
return [...map.values()]
.map((v) => ({ ...v.hit, rrf: v.rrf }))
.sort((a, b) => b.rrf - a.rrf);
}
RRF 파라미터 가이드
k기본값은 60이 흔하지만, topK가 작고(예: 20) 상위권을 더 강하게 믿고 싶으면k를 10~30으로 낮춰볼 수 있습니다.- 리트리버별 신뢰도가 다르면
weights로 조절합니다.- 예: 에러 코드/버전 문자열 질의가 많으면 키워드 가중치를 올립니다.
- 반대로 자연어 질문이 많으면 벡터 가중치를 올립니다.
운영 팁은 “가중합보다 가중 RRF”가 튜닝이 훨씬 단순하다는 점입니다.
2단계: MMR로 ‘비슷한 결과만 잔뜩’ 문제 해결
RRF로 합치면 관련도는 좋아지지만, 상위 결과가 서로 거의 같은 문서(같은 페이지의 조각, 같은 섹션 반복)로 채워지는 경우가 많습니다. 이때 LLM 답변이 단조로워지고, 사용자는 “다 똑같은 것만 나오네”라고 느낍니다.
MMR은 다음 목표를 동시에 최적화합니다.
- 쿼리와의 관련도(relevance)
- 이미 선택된 결과들과의 비유사도(diversity)
전형적인 MMR 선택 규칙은 다음 형태입니다.
argmax_d [ lambda * sim(q, d) - (1 - lambda) * max_{s in selected} sim(d, s) ]
여기서 lambda는 관련도 vs 다양성 트레이드오프입니다.
MMR 구현 예시(Python)
아래 예시는 RRF로 뽑은 후보 topN에 대해 MMR로 최종 topK를 고르는 흐름입니다. sim은 코사인 유사도를 가정하고, 문서 임베딩은 Pinecone/Milvus에서 함께 저장해두거나 별도 캐시로 가져옵니다.
from typing import List, Dict
import numpy as np
def cosine(a: np.ndarray, b: np.ndarray) -> float:
a = a / (np.linalg.norm(a) + 1e-12)
b = b / (np.linalg.norm(b) + 1e-12)
return float(np.dot(a, b))
# candidates: [{"id": str, "vec": np.ndarray, "rel": float}]
# rel은 RRF 점수 또는 벡터 유사도/재랭커 점수 등 "관련도"로 쓸 값
def mmr_select(candidates: List[Dict], query_vec: np.ndarray, top_k: int, lam: float = 0.7):
selected = []
selected_ids = set()
# 미리 q-d 유사도 계산
q_sims = {c["id"]: cosine(query_vec, c["vec"]) for c in candidates}
while len(selected) < min(top_k, len(candidates)):
best = None
best_score = -1e9
for c in candidates:
cid = c["id"]
if cid in selected_ids:
continue
relevance = c.get("rel", 0.0)
# 이미 뽑힌 것들과의 최대 유사도
if not selected:
redundancy = 0.0
else:
redundancy = max(cosine(c["vec"], s["vec"]) for s in selected)
score = lam * relevance - (1.0 - lam) * redundancy
# 필요하면 쿼리 유사도를 relevance에 섞을 수도 있음
# score = lam * (0.5 * relevance + 0.5 * q_sims[cid]) - (1-lam) * redundancy
if score > best_score:
best_score = score
best = c
if best is None:
break
selected.append(best)
selected_ids.add(best["id"])
return selected
MMR 파라미터 가이드
lam을 0.6~0.8에서 시작하세요.- 0.8 이상: 관련도 우선, 다양성 약함
- 0.5 근처: 다양성 강함, 관련도 희생
- MMR은 후보 풀 크기에 민감합니다.
- 최종 topK가 10이면 후보는 50~200 정도로 시작해보는 것이 일반적입니다.
운영에서 자주 쓰는 패턴은 RRF top200을 만든 뒤 MMR top20으로 다듬고, 그 top20을 LLM 컨텍스트로 넣는 방식입니다.
3단계: Pinecone·Milvus 공통 튜닝 플로우
(1) 리트리버 별 topK를 넉넉히
RRF와 MMR은 “후처리”이므로, 각 리트리버에서 충분한 후보를 가져와야 합니다.
- 키워드 topK: 50~200
- 벡터 topK: 50~200
단, 지연시간이 민감하면 쿼리 타입에 따라 동적으로 줄이는 전략이 필요합니다.
(2) 쿼리 타입 라우팅(가중치 자동 조절)
질의가 다음에 해당하면 키워드 가중치를 올리면 체감이 좋아집니다.
- 에러 코드, 로그 토큰, 버전 문자열(예:
E11000,v1.2.3) - 정확한 식별자(테이블명, 클래스명, API 경로)
반대로 자연어 질문은 벡터 비중을 올립니다.
간단한 휴리스틱 예시:
export function guessQueryType(q: string): "keyword" | "semantic" | "mixed" {
const hasCodeLike = /[A-Za-z_]+\.[A-Za-z_]+|\bE\d{3,5}\b|v\d+\.\d+/.test(q);
const hasHangulOrSentence = q.length >= 12 && /\s/.test(q);
if (hasCodeLike && !hasHangulOrSentence) return "keyword";
if (hasHangulOrSentence && !hasCodeLike) return "semantic";
return "mixed";
}
그 다음 weights를 조절합니다.
- keyword:
weights = [1.5, 0.7] - semantic:
weights = [0.7, 1.5] - mixed:
weights = [1.0, 1.0]
(3) 청크 전략이 하이브리드 품질을 좌우
하이브리드가 잘 안 맞는 경우, 랭킹 알고리즘 문제가 아니라 청크 설계 문제인 경우가 많습니다.
- 너무 작은 청크: 키워드에는 걸리지만 의미가 빈약해 벡터에서 약함
- 너무 큰 청크: 벡터는 강하지만 키워드 정확도가 떨어지고 중복이 증가
추천은
- 본문은 300~800 토큰 사이(도메인에 따라)
- 제목/섹션 헤더를 청크에 포함
- 같은 문서에서 나온 청크가 상위에 과도하게 몰리면 MMR 또는 “문서 단위 그룹핑”을 추가
(4) 문서 단위 디듀프(중복 제거)
MMR만으로도 개선되지만, 더 단순하고 강력한 방법은 “같은 문서에서 상위 N개까지만 허용” 같은 룰입니다.
export function capPerDoc<T extends { docId: string }>(hits: T[], cap: number): T[] {
const cnt = new Map<string, number>();
const out: T[] = [];
for (const h of hits) {
const c = (cnt.get(h.docId) ?? 0) + 1;
cnt.set(h.docId, c);
if (c <= cap) out.push(h);
}
return out;
}
RRF 결과에 capPerDoc을 먼저 적용하고, 그 다음 MMR을 적용하면 “한 문서가 상위를 도배”하는 현상이 크게 줄어듭니다.
4단계: 평가 지표와 실전 디버깅 체크리스트
오프라인 평가
최소한 아래는 갖추는 것이 좋습니다.
- 골든 쿼리 50~200개(실제 검색 로그 기반)
- 정답 문서(또는 정답 청크) 라벨링
- 지표: Recall@K, MRR, nDCG
하이브리드에서는 특히 Recall@K가 중요합니다. RAG는 1등 문서 하나보다 “답에 필요한 근거가 topK에 들어오느냐”가 성패를 가르기 때문입니다.
온라인 디버깅(운영 체크리스트)
문제가 생길 때 아래 순서로 보면 빠릅니다.
- 키워드 리트리버만 봤을 때 topK에 정답이 있는가
- 벡터 리트리버만 봤을 때 topK에 정답이 있는가
- 둘 중 하나에는 있는데 RRF 후에 밀리는가
- RRF는 괜찮은데 MMR 후에 밀리는가(다양성 과다)
- 특정 문서/특정 섹션이 중복으로 상위를 점유하는가(청크/디듀프 이슈)
이 과정은 분산 트랜잭션에서 장애를 쪼개 원인을 찾는 방식과 유사합니다. 운영에서 “어디서 깨졌는지” 단계를 분리하는 습관이 중요합니다. 비슷한 문제 분해 사고는 Saga 패턴 보상 트랜잭션 설계·디버깅 8단계 글의 체크리스트 접근도 참고가 됩니다.
5단계: Pinecone·Milvus 적용 아키텍처 예시
아래는 DB 종류와 무관하게 통하는 전형적인 파이프라인입니다.
- Query 이해(휴리스틱 또는 분류기)
- 키워드 검색(예: OpenSearch)
- 벡터 검색(Pinecone 또는 Milvus)
- RRF로 합치기
- 문서 단위 디듀프
- MMR로 다양성 확보
- (선택) Cross-Encoder 재랭킹
- LLM 컨텍스트 구성
지연시간이 빡빡하면, 7번 재랭킹은 top20 같은 작은 후보에만 적용합니다.
프론트에서 검색 UX까지 포함해 최적화한다면, 네트워크/렌더링 병목도 같이 봐야 합니다. 검색 결과 페이지의 체감 속도는 결국 INP에 영향을 주므로 React/Next.js 프론트 최적화로 INP 200ms 달성 같은 관점도 함께 가져가면 좋습니다.
자주 하는 실수 6가지
- 점수 정규화 후 가중합으로 끝내기(분포가 바뀌면 즉시 흔들림)
- 리트리버 topK를 너무 작게 가져오기(RRF/MMR이 일할 여지가 없음)
- 청크 중복을 방치하고 “MMR이 해결해주겠지”라고 기대하기
- MMR
lam을 너무 낮춰 관련도를 과하게 희생하기 - 쿼리 타입(정확 일치형 vs 자연어형)을 구분하지 않고 한 세트 파라미터로 운영하기
- 오프라인 지표 없이 “좋아 보인다”로 튜닝하기
결론: RRF는 안전장치, MMR은 품질 레버
- RRF는 하이브리드 검색에서 점수 스케일 문제를 우회해 “합치기”를 안정화합니다.
- MMR은 중복·편향을 줄여 결과를 더 탐색적으로 만들고, RAG 컨텍스트 품질을 끌어올립니다.
추천 시작점은 다음 조합입니다.
- 키워드 topK 100, 벡터 topK 100
- RRF
k60, 가중치 1:1에서 시작 - 문서당 cap 2
- MMR 후보 200, 최종 20,
lam0.7
여기서 쿼리 타입별로 가중치와 lam만 분기해도, Pinecone·Milvus 어느 쪽이든 운영 품질이 눈에 띄게 안정됩니다.