안녕하세요. 자바파크입니다.
영상에서는 BM25와 벡터 검색의 서로 다른 점수를 순위로 바꿔 합치는 과정을 1분 안에 시각화했습니다. 아래 글에서는 실제 계산과 Elasticsearch 구현, 운영 튜닝 기준까지 이어서 설명합니다.
하이브리드 검색은 보통 두 종류의 검색 결과를 함께 사용합니다.
- 키워드 검색: BM25처럼 정확한 단어·구문 일치에 강합니다.
- 벡터 검색: 표현이 달라도 의미가 비슷한 문서를 찾는 데 강합니다.
문제는 두 검색기가 서로 다른 점수를 반환한다는 것입니다.
Keyword score: 12.7
Vector score: 0.83이 숫자를 그대로 더하면 13.53이 됩니다. 계산은 가능하지만 의미 있는 비교는 아닙니다. BM25 점수와 코사인 유사도는 범위와 분포가 다르기 때문입니다. 숫자가 큰 검색기가 최종 순위를 지배할 수 있습니다.
**RRF(Reciprocal Rank Fusion)**는 이 문제를 원점수 정규화 없이 해결하는 실용적인 방법입니다.
RRF는 서로 다른 검색 점수를 직접 비교하지 않습니다. 각 검색 결과의 순위를 점수로 바꾸고, 문서별로 합산합니다.
이번 글에서는 RRF 공식을 손으로 계산한 뒤 Elasticsearch의 rrf retriever로 구현하고, 운영에서 조정해야 하는 후보 창·지연시간·평가 지표까지 정리합니다.
왜 원점수를 그대로 더하면 안 될까
같은 질문을 키워드 검색과 벡터 검색에 보냈다고 가정해 보겠습니다.
| 순위 | Keyword/BM25 | 점수 | Vector | 점수 |
|---|---|---|---|---|
| 1 | A · JVM 튜닝 체크리스트 | 12.7 | B · JVM 메모리 누수 해결 | 0.83 |
| 2 | B · JVM 메모리 누수 해결 | 8.4 | D · GC 로그 진단 | 0.79 |
| 3 | C · Heap Dump 분석 | 5.1 | A · JVM 튜닝 체크리스트 | 0.72 |
BM25 점수는 질의·문서 길이·단어 빈도에 따라 달라집니다. 벡터 검색 점수는 사용하는 거리 함수와 변환 방식에 따라 범위가 달라집니다. 두 숫자는 같은 단위가 아닙니다.
원점수를 결합하려면 보통 정규화와 가중치가 필요합니다.
final = 0.4 × normalized_bm25 + 0.6 × normalized_vector이 방식은 검색별 영향력을 직접 통제할 수 있다는 장점이 있습니다. 하지만 정규화 방식과 가중치를 정하려면 평가 데이터가 필요합니다. 질의 유형이 바뀌면 최적값도 달라질 수 있습니다.
RRF는 다른 선택을 합니다. 점수의 크기를 버리고 순위만 사용합니다.
RRF 공식
문서 d의 RRF 점수는 다음과 같습니다.
RRF(d) = Σ 1 / (k + rank_i(d))rank_i(d): i번째 검색 결과에서 문서 d의 순위, 1부터 시작k: 상위 순위와 하위 순위 사이의 점수 차이를 완만하게 만드는 상수- 문서가 특정 검색 결과 후보 창에 없다면 그 목록의 기여도는 0
Elastic의 최신 RRF retriever에서 rank_constant 기본값은 60입니다. 값이 커질수록 하위 순위 문서의 영향력이 상대적으로 커집니다.
앞의 결과를 k=60으로 계산해 보겠습니다.
B 문서
B는 키워드 검색 2위, 벡터 검색 1위입니다.
B = 1/(60+2) + 1/(60+1)
= 0.016129 + 0.016393
= 0.032522A 문서
A는 키워드 검색 1위, 벡터 검색 3위입니다.
A = 1/(60+1) + 1/(60+3)
= 0.016393 + 0.015873
= 0.032266최종적으로 B가 A보다 근소하게 앞섭니다.
1. B · 0.032522
2. A · 0.032266B는 어느 한 검색에서 압도적인 원점수를 받은 문서가 아닙니다. 대신 두 검색 결과에서 모두 꾸준히 높은 순위를 받았습니다. 이것이 RRF가 하이브리드 검색에서 유용한 이유입니다.
RRF를 직접 구현해 보기
검색기에서 상위 후보 목록을 이미 받았다면 구현은 단순합니다.
function reciprocalRankFusion(resultLists, k = 60) {
const scores = new Map()
for (const results of resultLists) {
results.forEach((document, index) => {
const rank = index + 1
const previous = scores.get(document.id) ?? {
document,
score: 0,
ranks: [],
}
previous.score += 1 / (k + rank)
previous.ranks.push(rank)
scores.set(document.id, previous)
})
}
return [...scores.values()].sort((a, b) => b.score - a.score)
}
const keyword = [
{ id: "A", title: "JVM 튜닝 체크리스트" },
{ id: "B", title: "JVM 메모리 누수 해결" },
{ id: "C", title: "Heap Dump 분석" },
]
const vector = [
{ id: "B", title: "JVM 메모리 누수 해결" },
{ id: "D", title: "GC 로그 진단" },
{ id: "A", title: "JVM 튜닝 체크리스트" },
]
console.table(reciprocalRankFusion([keyword, vector]))실제 서비스에서는 반드시 동일한 문서를 식별할 수 있는 안정적인 ID로 합쳐야 합니다. 제목이나 본문 문자열을 키로 사용하면 중복·수정·다국어 문서에서 잘못 병합될 수 있습니다.
Elasticsearch RRF retriever 예제
Elasticsearch에서는 하나의 검색 요청 안에 키워드와 kNN retriever를 넣고 RRF로 결합할 수 있습니다.
POST documents/_search
{
"size": 10,
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"match": {
"content": "JVM 메모리 누수 해결"
}
}
}
},
{
"knn": {
"field": "content_embedding",
"query_vector": [0.12, -0.08, 0.31],
"k": 50,
"num_candidates": 100
}
}
],
"rank_window_size": 50,
"rank_constant": 60
}
}
}실행 흐름은 다음과 같습니다.
Keyword top N ─┐
├─ RRF ─ Top K ─ optional Reranker
Vector top N ──┘두 child retriever는 독립적으로 후보를 찾습니다. coordinating node는 문서 ID를 기준으로 후보를 합치고 RRF 점수를 계산한 뒤 요청의 size만큼 반환합니다.
Elastic 공식 문서 기준으로 RRF에는 최소 두 개의 child retriever가 필요하며, 각 retriever는 공식에서 동일한 가중치로 참여합니다. 검색별 가중치를 직접 주고 싶다면 Linear retriever가 더 적합합니다.
rank_constant와 rank_window_size
rank_constant
rank_constant는 공식의 k입니다.
1 / (rank_constant + rank)- 작은 값: 최상위 문서의 영향력이 더 강해집니다.
- 큰 값: 하위 순위 문서도 비교적 비슷한 기여도를 가집니다.
기본값 60을 무조건 최적값으로 받아들이기보다, 검색 로그에서 만든 평가 질의로 비교해야 합니다. 단, 가중치를 직접 통제하려는 목적으로 rank_constant를 과도하게 조정하는 것은 이해하기 어렵습니다. 그 요구는 Linear Fusion으로 표현하는 편이 명확합니다.
rank_window_size
rank_window_size는 각 검색기에서 RRF에 전달할 후보 수입니다.
- 작으면 빠르지만 결합할 후보가 부족해 Recall이 떨어질 수 있습니다.
- 크면 더 많은 후보를 결합하지만 CPU·메모리·네트워크·지연시간 비용이 증가합니다.
- 최종
size이상이어야 합니다.
벡터 검색의 k가 rank_window_size보다 크면 RRF 단계에서 후보가 잘립니다. 반대로 벡터 k가 더 작으면 그만큼의 후보만 참여합니다.
운영에서는 후보 창을 품질 지표와 함께 측정해야 합니다.
| 후보 창 | Recall@10 | NDCG@10 | p95 latency |
|---|---|---|---|
| 20 | 0.81 | 0.72 | 24ms |
| 50 | 0.89 | 0.78 | 43ms |
| 100 | 0.92 | 0.79 | 82ms |
숫자는 예시입니다. 실제 값은 인덱스 크기, ANN 설정, 질의 분포와 하드웨어에서 측정해야 합니다. 품질이 거의 오르지 않는데 지연시간만 증가하는 구간을 찾는 것이 목적입니다.
벡터 후보 검색 자체가 느리거나 Recall이 낮다면 HNSW의 ef_search와 필터링 문제도 함께 확인해야 합니다.
RRF, Linear Fusion, Reranker 선택 기준
세 방법은 서로 완전히 대체하는 관계가 아닙니다.
| 상황 | 권장 출발점 | 이유 |
|---|---|---|
| 점수 체계가 다르고 보정 데이터가 없음 | RRF | 정규화·가중치 없이 안정적으로 시작 가능 |
| 키워드와 벡터의 영향력을 명시적으로 통제 | Linear Fusion | 정규화된 원점수와 가중치를 사용 |
| 상위 몇 건의 정밀도가 매우 중요 | RRF + Reranker | 빠른 후보 검색 후 더 비싼 모델로 재정렬 |
| 충분한 클릭·판정 데이터와 복잡한 랭킹 요구 | LTR | 여러 신호를 학습한 순위 함수 사용 |
RRF의 한계
RRF는 원점수 차이를 버립니다. 같은 1위라도 압도적인 1위와 간신히 올라온 1위를 동일하게 취급합니다. 또한 기본 공식에서는 각 검색기가 동일한 비중으로 참여합니다.
이 특성이 장점일 때도 있고 한계일 때도 있습니다.
- 데이터가 부족하고 빠르게 기준선을 만들 때: 장점
- 특정 검색 신호를 강하게 반영해야 할 때: 한계
- 상위 문서의 세밀한 의미 판단이 필요할 때: 한계
Linear Fusion은 정규화 후 가중합으로 검색별 영향력을 통제합니다. Cross-encoder Reranker는 질의와 후보 문서를 함께 입력해 더 정밀하게 재정렬하지만, 모델 추론 비용과 지연시간이 추가됩니다.
실무에서 자주 쓰는 구조는 다음과 같습니다.
BM25 + Vector
↓
RRF 빠른 1차 결합
↓
Top 20~100
↓
Cross-encoder 선택적 2차 정렬
↓
Final Top KRAG 파이프라인에서는 이 최종 Top K가 LLM 컨텍스트로 들어갑니다. 따라서 검색 품질뿐 아니라 토큰 비용과 응답 지연에도 영향을 줍니다.
운영 체크리스트
RRF를 붙였다는 사실만으로 검색 품질이 보장되지는 않습니다.
1. 평가 질의를 만든다
실제 사용자 질의에서 다음 유형을 골고루 포함합니다.
- 제품명·오류 코드처럼 정확한 단어가 중요한 질의
- 자연어 설명처럼 의미 유사성이 중요한 질의
- 두 신호가 모두 필요한 질의
- 검색 결과가 없어야 하는 질의
2. 기준 정답을 만든다
질의마다 관련 문서와 관련도 등급을 정의합니다. 클릭 로그만 사용하면 기존 랭킹의 위치 편향이 섞일 수 있으므로 담당자 판정이나 보정된 로그를 함께 사용합니다.
3. 품질과 비용을 함께 본다
Recall@K: 관련 문서를 후보군에서 놓치지 않았는가NDCG@K: 관련도가 높은 문서가 앞에 배치됐는가MRR: 첫 관련 문서가 얼마나 위에 있는가- p50·p95·p99 latency
- 검색 노드 CPU·메모리와 벡터 검색 비용
4. 실패 질의를 분류한다
평균 점수 하나만 보면 개선 방향을 찾기 어렵습니다.
exact-term miss
semantic miss
filter miss
stale document
duplicate document
wrong-language result어느 검색기가 후보를 놓쳤는지, RRF에서 순위가 밀렸는지, Reranker가 뒤집었는지를 단계별로 기록해야 합니다.
자주 묻는 질문
RRF는 점수 정규화가 필요 없나요?
네. RRF는 원점수가 아니라 각 결과 목록의 순위를 사용하므로 BM25와 벡터 점수를 같은 범위로 정규화하지 않아도 됩니다. 이것이 RRF의 가장 큰 실용적 장점입니다.
rank_constant가 검색기 가중치인가요?
아닙니다. rank_constant는 모든 결과 목록에 공통으로 적용되는 순위 완화 상수입니다. 키워드와 벡터 검색에 서로 다른 비중을 주려면 정규화와 가중치를 지원하는 Linear Fusion을 검토해야 합니다.
RRF를 쓰면 Reranker가 필요 없나요?
목표 품질에 따라 다릅니다. RRF는 여러 검색기의 후보를 빠르고 안정적으로 합치는 1차 결합 방법입니다. 상위 결과의 문맥적 정확도가 매우 중요하다면 RRF로 후보를 줄인 뒤 Cross-encoder Reranker를 적용할 수 있습니다.
후보 수는 많을수록 좋은가요?
항상 그렇지는 않습니다. 후보 창을 늘리면 Recall이 좋아질 수 있지만 지연시간과 자원 사용량도 증가합니다. Recall@K 또는 NDCG@K와 p95 latency를 함께 비교해 포화 지점을 찾아야 합니다.
정리
RRF의 핵심은 간단합니다.
- 키워드 검색과 벡터 검색에서 각각 상위 후보를 가져옵니다.
- 원점수를 버리고 각 목록의 순위를 사용합니다.
- 문서마다
1/(k+rank)를 계산해 합산합니다. - 합산 점수로 최종 Top K를 반환합니다.
- 후보 수와 품질·지연시간을 함께 측정합니다.
보정 데이터가 없고 서로 다른 검색 결과를 빠르게 결합해야 한다면 RRF는 좋은 출발점입니다. 검색별 영향력을 명시적으로 통제해야 한다면 Linear Fusion, 상위 결과의 최고 정밀도가 필요하다면 Reranker를 추가합니다.
중요한 것은 특정 알고리즘을 고집하는 것이 아니라 후보를 놓치지 않는 품질과 사용자가 체감하는 지연시간을 함께 측정하는 것입니다.