하이브리드 검색과 RRF: 키워드 검색과 의미 검색을 한 줄로 합치기

하이브리드 검색과 RRF: 키워드 검색과 의미 검색을 한 줄로 합치기

지난 두 편에서 검색의 두 갈래를 따로 살펴봤는데요. BM25 키워드 검색은 단어가 정확히 일치할 때 강하고, 의미 검색은 표현이 달라도 뜻으로 문서를 찾아줍니다. 그리고 두 글 모두 마지막에 “둘을 합치면 어떨까?”라는 떡밥을 남겨뒀죠. 키워드 검색의 정확함과 의미 검색의 유연함을 동시에 누리자는 게 하이브리드 검색(hybrid search)의 아이디어입니다.

말은 간단한데, 막상 합치려고 하면 곧바로 벽에 부딪힙니다. 두 검색기가 내놓는 점수가 완전히 다른 단위거든요. BM25는 0에서 수십까지 제한 없이 커지는 점수를 주고, 의미 검색은 보통 0과 1 사이의 코사인 유사도를 줍니다. 사과와 오렌지를 더하려는 격이죠. 이번 글에서는 이 문제를 우아하게 푸는 RRF(Reciprocal Rank Fusion, 상호 순위 융합)라는 기법을 직접 계산해보며 익혀보겠습니다.

점수를 그냥 더하면 안 되는 이유

가장 단순한 발상은 두 검색기의 점수를 그냥 더하는 겁니다. 한번 그렇게 해보면 무슨 일이 벌어지는지 바로 드러나요. 같은 쿼리에 대해 키워드 검색과 의미 검색이 각각 내놓은 점수를 문서별로 합산해봤습니다.

naive_sum.py
keyword = {"환불 규정 안내": 18.4, "결제 취소 방법": 12.1, "교환 및 반품 정책": 9.7, "배송 조회 방법": 4.2}
semantic = {"결제를 취소하고 대금을 돌려받는 절차": 0.81, "환불 규정 안내": 0.76,
            "주문을 무르는 방법": 0.71, "결제 취소 방법": 0.55}

docs = set(keyword) | set(semantic)
naive = {d: keyword.get(d, 0) + semantic.get(d, 0) for d in docs}

for rank, (doc, s) in enumerate(sorted(naive.items(), key=lambda x: -x[1]), 1):
    print(f"{rank}{s:.2f}  {doc}")
결과
1위  19.16  환불 규정 안내
2위  12.65  결제 취소 방법
3위  9.70  교환 반품 정책
4위  4.20  배송 조회 방법
5위  0.81  결제를 취소하고 대금을 돌려받는 절차
6위  0.71  주문을 무르는 방법

자바스크립트로 옮겨도 결과는 똑같습니다. 객체와 Set만으로 충분해요.

naive_sum.js
const keyword = {
  "환불 규정 안내": 18.4,
  "결제 취소 방법": 12.1,
  "교환 및 반품 정책": 9.7,
  "배송 조회 방법": 4.2,
};
const semantic = {
  "결제를 취소하고 대금을 돌려받는 절차": 0.81,
  "환불 규정 안내": 0.76,
  "주문을 무르는 방법": 0.71,
  "결제 취소 방법": 0.55,
};

const docs = new Set([...Object.keys(keyword), ...Object.keys(semantic)]);
const naive = {};
for (const d of docs) naive[d] = (keyword[d] ?? 0) + (semantic[d] ?? 0);

Object.entries(naive)
  .sort((a, b) => b[1] - a[1])
  .forEach(([doc, s], i) => console.log(`${i + 1}${s.toFixed(2)}  ${doc}`));

결과를 보면 키워드 검색의 점수가 모든 걸 지배합니다. BM25가 매긴 18.4, 12.1 같은 큰 숫자에 비하면 코사인 유사도 0.81은 반올림 오차 수준이라, 의미 검색이 1위로 꼽았던 “결제를 취소하고 대금을 돌려받는 절차”는 5위까지 밀려나 버렸어요. 사실상 의미 검색의 판단이 통째로 무시된 셈입니다. 점수의 단위가 다르니 큰 쪽이 작은 쪽을 깔아뭉개는 거죠.

정규화로는 부족하다

그럼 점수의 범위를 맞춰주면 되지 않을까요? 각 검색기의 점수를 0과 1 사이로 변환하는 min-max 정규화가 흔히 떠오르는 해법입니다. 각 점수에서 최솟값을 빼고 (최댓값 − 최솟값)으로 나누는 방식이죠.

이 방법도 어느 정도는 통하지만, 실무에서 믿고 쓰기엔 약점이 많습니다. 우선 이상치(outlier)에 취약해요. 어떤 문서 하나가 유독 높은 점수를 받으면 그 값이 최댓값이 되어 나머지 점수를 전부 0 근처로 찌그러뜨립니다. 게다가 점수 분포가 쿼리마다 달라서, 어떤 쿼리에서는 정규화가 잘 먹히고 다른 쿼리에서는 엉뚱한 결과가 나오기도 해요. 검색기를 두 개에서 세 개, 네 개로 늘리면 각각의 분포를 맞추는 일이 점점 더 까다로워지고요.

근본 원인은 우리가 점수의 절댓값에 매달린다는 데 있습니다. 그런데 검색에서 정작 중요한 정보는 점수 그 자체가 아니라 “이 문서가 몇 번째로 관련 있는가”, 즉 순위예요. 여기에 착안한 게 바로 RRF입니다.

순위만 쓰는 RRF

RRF의 발상은 시원할 만큼 단순합니다. 점수는 아예 쳐다보지도 말고, 각 검색기가 매긴 순위(rank)만 가지고 결과를 합치자는 거예요. 순위는 1위, 2위, 3위처럼 검색기 종류와 상관없이 똑같은 단위라서, 스케일을 맞추는 고민이 통째로 사라집니다. BM25가 18점을 주든 1800점을 주든, 그 문서가 1위라는 사실만 쓰면 되니까요.

공식은 이렇게 생겼습니다. 각 문서에 대해, 그 문서가 등장한 모든 검색 결과에서의 순위를 가져와 다음 값을 더합니다.

RRF 점수 공식
RRF(d) = Σ   1 / (k + rank_r(d))
         r

여기서 rank_r(d)는 검색기 r의 결과에서 문서 d의 순위(1부터 시작)이고, k는 보통 60으로 두는 상수입니다. 순위가 낮을수록(즉 1위에 가까울수록) 분모가 작아져서 더 큰 점수를 받고, 여러 검색기에서 두루 상위에 오른 문서일수록 여러 항이 더해져 총점이 높아집니다. 즉 RRF는 “여러 검색기가 입을 모아 추천한 문서”에 자연스럽게 가산점을 주는 구조예요.

직접 RRF로 합쳐보기

이제 앞에서 단순 합산이 망쳤던 그 결과를 RRF로 다시 합쳐봅시다. 점수는 무시하고 순위만 쓴다는 점에 주목해서 보세요.

rrf.py
keyword_results = [  # BM25(키워드) 순위
    ("환불 규정 안내", 18.4),
    ("결제 취소 방법", 12.1),
    ("교환 및 반품 정책", 9.7),
    ("배송 조회 방법", 4.2),
]
semantic_results = [  # 의미 검색(코사인 유사도) 순위
    ("결제를 취소하고 대금을 돌려받는 절차", 0.81),
    ("환불 규정 안내", 0.76),
    ("주문을 무르는 방법", 0.71),
    ("결제 취소 방법", 0.55),
]

def rrf(result_lists, k=60):
    scores = {}
    for results in result_lists:
        for rank, (doc, _score) in enumerate(results, start=1):
            # 점수(_score)는 쓰지 않고 순위(rank)만 사용
            scores[doc] = scores.get(doc, 0) + 1 / (k + rank)
    return sorted(scores.items(), key=lambda x: -x[1])

for rank, (doc, score) in enumerate(rrf([keyword_results, semantic_results]), 1):
    print(f"{rank}{score:.5f}  {doc}")
결과
1위  0.03252  환불 규정 안내
2위  0.03175  결제 취소 방법
3위  0.01639  결제를 취소하고 대금을 돌려받는 절차
4위  0.01587  교환 반품 정책
5위  0.01587  주문을 무르는 방법
6위  0.01562  배송 조회 방법

이 RRF 융합도 자바스크립트로 거의 그대로 옮겨집니다. 점수는 버리고 순위만 쓴다는 핵심도 똑같고요.

rrf.js
const keywordResults = [
  // BM25(키워드) 순위
  ["환불 규정 안내", 18.4],
  ["결제 취소 방법", 12.1],
  ["교환 및 반품 정책", 9.7],
  ["배송 조회 방법", 4.2],
];
const semanticResults = [
  // 의미 검색(코사인 유사도) 순위
  ["결제를 취소하고 대금을 돌려받는 절차", 0.81],
  ["환불 규정 안내", 0.76],
  ["주문을 무르는 방법", 0.71],
  ["결제 취소 방법", 0.55],
];

function rrf(resultLists, k = 60) {
  const scores = {};
  for (const results of resultLists) {
    results.forEach(([doc, _score], idx) => {
      const rank = idx + 1; // 점수(_score)는 쓰지 않고 순위(rank)만 사용
      scores[doc] = (scores[doc] ?? 0) + 1 / (k + rank);
    });
  }
  return Object.entries(scores).sort((a, b) => b[1] - a[1]);
}

rrf([keywordResults, semanticResults]).forEach(([doc, score], i) =>
  console.log(`${i + 1}${score.toFixed(5)}  ${doc}`),
);

실행하면 위와 같은 순위가 그대로 나옵니다. 양쪽 결과에 모두 이름을 올린 “환불 규정 안내”가 1위로 올라오죠.

결과가 확 달라졌죠. 1위는 “환불 규정 안내”인데, 이 문서는 키워드 검색에서 1위, 의미 검색에서 2위로 양쪽 모두에서 상위에 올랐습니다. 두 검색기가 함께 인정한 문서라 RRF 점수가 가장 높게 나온 거예요.

이 1위 점수가 어떻게 나왔는지 손으로 따라가 보면 공식이 한결 또렷해집니다. “환불 규정 안내”는 키워드 결과에서 1위, 의미 결과에서 2위였으니, 두 항을 더하면 이렇게 됩니다.

환불 규정 안내의 RRF 점수
1/(60 + 1) + 1/(60 + 2) = 1/61 + 1/62 ≈ 0.01639 + 0.01613 = 0.03252

출력에 찍힌 0.03252와 정확히 일치하죠. 만약 어떤 문서가 한 검색기에만 등장하고 다른 검색기에는 없다면 항이 하나뿐이라 점수가 그만큼 작아집니다. 그래서 두 검색기에 모두 이름을 올린 문서가 자연스럽게 위로 올라오는 거예요. 2위 “결제 취소 방법” 역시 양쪽 결과에 모두 등장한 문서입니다.

반면 단순 합산에서 5위로 묻혔던 “결제를 취소하고 대금을 돌려받는 절차”는 3위로 올라왔습니다. 한쪽 검색기에만 등장했지만 거기서 1위였으니 정당한 대접을 받은 거죠. 점수의 크기가 아니라 순위로 판단하니, 단위가 다른 두 검색기의 의견이 공평하게 반영됐습니다.

k 파라미터는 무슨 일을 할까

RRF에서 손댈 수 있는 유일한 손잡이가 상수 k입니다. 원 논문에서 60을 제안한 뒤로 사실상 기본값처럼 굳어졌는데, 이 값이 무엇을 조절하는지 알아두면 좋아요.

k는 순위 사이의 점수 격차를 얼마나 완만하게 만들지를 정합니다. k가 작으면(예: 1) 1위와 2위의 점수 차이가 크게 벌어져서 각 검색기의 최상위 결과가 강하게 우대받습니다. 반대로 k가 크면(예: 60) 1위든 5위든 점수가 고만고만해져서, 어느 한 검색기의 1위 하나에 휘둘리기보다 여러 검색기에 두루 등장하는지를 더 중요하게 봅니다. 기본값 60은 상위권의 작은 순위 차이에 지나치게 민감하게 반응하지 않도록 적당히 완충하는 값이에요. 대부분의 경우 60에서 시작해 검색 품질을 보며 조정하면 충분합니다.

RRF의 장점과 한계

RRF가 널리 쓰이는 이유는 분명합니다. 우선 점수 스케일을 맞출 필요가 전혀 없어서 단위가 다른 검색기를 그냥 꽂아 넣을 수 있어요. 구현도 방금 본 것처럼 십여 줄이면 끝나고, 튜닝할 파라미터도 k 하나뿐입니다. 검색기를 두 개에서 세 개, 네 개로 늘려도 결과 리스트만 추가하면 되니 확장도 쉽고요. 이렇게 간단한데 실제 성능까지 준수해서, 복잡한 학습 기반 융합 모델 못지않은 결과를 자주 보여줍니다.

물론 공짜 점심은 아니에요. RRF는 순위만 쓰기 때문에 점수에 담긴 크기 정보를 버립니다. 1위 문서가 2위를 압도적으로 앞섰든 간발의 차로 앞섰든, RRF에게는 그냥 “1위와 2위”일 뿐이에요. 정말 확신에 찬 매칭과 아슬아슬한 매칭을 구분하지 못하는 거죠. 또 기본 RRF는 모든 검색기를 동등하게 취급하는데, “우리 도메인에선 키워드 검색을 더 신뢰한다”처럼 검색기마다 비중을 다르게 주고 싶다면 각 항에 가중치를 곱하는 가중 RRF(weighted RRF) 같은 변형을 써야 합니다.

실무에서의 RRF

직접 구현해도 간단하지만, 요즘은 검색 엔진이 RRF를 아예 내장하고 있어서 더 편합니다. Elasticsearch는 rrf라는 검색 옵션으로 키워드 질의(BM25)와 벡터 질의(kNN)의 결과를 한 번에 융합해주고, OpenSearch도 하이브리드 검색 파이프라인에서 비슷한 기능을 제공해요. 직접 만드는 경우라도 pgvector로 의미 검색을 구현한 결과와 BM25 결과를 애플리케이션 단에서 앞의 rrf 함수로 합치면 그만입니다. 두 검색을 병렬로 던지고, 각 결과의 순위를 모아 융합 점수를 매기고, 그 순서대로 보여주는 흐름이죠.

여기서 한 가지 실무 팁을 더하자면, 각 검색기에서 너무 적은 수의 결과만 가져오지 않는 게 좋아요. 최종적으로 10개를 보여줄 거라도 각 검색기에서 상위 50~100개씩은 가져와서 융합해야, 한쪽에만 등장하는 좋은 문서가 후보 단계에서 잘려나가지 않습니다.

마치며

세 편에 걸쳐 검색의 큰 그림을 그려봤습니다. BM25로 단어를 정확히 맞추고, 의미 검색으로 뜻을 이해하고, 이번 글의 RRF로 둘을 우아하게 합쳤어요. RRF의 교훈을 한마디로 줄이면 “점수가 아니라 순위를 믿어라”입니다. 단위가 제각각인 점수를 억지로 맞추는 대신, 누구나 똑같이 해석할 수 있는 순위로 바꿔 합치는 발상의 전환이 이 간결한 공식의 힘이죠.

다음 단계로는 가중 RRF로 검색기마다 비중을 달리 주거나, 융합한 상위 결과를 다시 정교하게 재정렬하는 리랭킹(reranking) 모델을 얹어 품질을 더 끌어올리는 방향을 살펴보시길 권합니다. 검색 품질을 한 단계 더 높이는 자연스러운 다음 주제예요.

RRF의 원래 정의가 궁금하다면 이 기법을 제안한 Reciprocal Rank Fusion 논문을 읽어보시면 좋습니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord