RRF 설정 하나 바꿨는데 검색 실행 경로가 달라졌다
하이브리드 검색에서 RRF의 상수를 조정한다고 하자. 검색은 같은 방식으로 실행하고, 마지막에 결과를 합칠 때 쓰는 숫자만 달라진다고 생각하기 쉽다.
Deep Connector의 검색 라우팅 코드를 확인하다가 이 가정이 성립하지 않는 분기를 만났다. 네이티브 하이브리드 검색을 지원하는 엔진에서도 rrf_k=60일 때와 rrf_k=61일 때 호출 경로가 달랐다. 숫자를 바꾸면 계산을 맡는 곳도 바뀌었다.
실제 검색 엔진 대신 호출 순서를 기록하는 가짜 엔진을 연결했다. 결과 목록보다 먼저 확인하고 싶었던 것은 “어떤 메서드가 어떤 모드로 실행됐는가”였다.
이 글은 특정 구현 스냅샷의 라우팅 함수를 추출해 실행한 설명용 실험이다. 고객 데이터, 운영 로그, 실제 검색 엔진은 사용하지 않았다. 과거 운영 장애를 재현한 기록이나 검색 품질 벤치마크가 아니다.
하이브리드를 지원한다는 말의 범위
하이브리드 검색은 벡터 검색과 키워드 검색의 결과를 함께 사용한다. RRF는 서로 다른 결과 목록의 순위를 이용해 문서별 점수를 합치는 방법이다. 원래 점수의 척도가 다른 검색 결과를 결합할 때 쓸 수 있다.
그런데 결과를 합치는 일을 누가 할지는 별도 문제다. 검색 엔진에 하이브리드 요청을 보내고 결과를 받을 수도 있고, 게이트웨이가 검색을 각각 요청한 뒤 직접 합칠 수도 있다.
검토한 구현은 엔진의 hybrid_search 지원 여부만 보고 위임하지 않았다. 하이브리드 모드 안에서 엔진 지원 여부, 퓨전 전략, RRF 상수를 함께 확인했다. 아래는 그 조건만 독립 함수로 줄인 설명용 코드다.
def can_delegate(native_hybrid, strategy, rrf_k):
return bool(native_hybrid) and strategy == "rrf" and rrf_k == 60
assert can_delegate(True, "rrf", 60)
assert not can_delegate(True, "rrf", 61)
assert not can_delegate(False, "rrf", 60)
assert not can_delegate(True, "weighted", 60)
소스 주석은 네이티브 구현의 고정 RRF 설정에 맞춰 위임 범위를 제한했다고 설명한다. 튜닝한 상수나 다른 전략을 요청하면 공용 퓨전 함수로 처리한다.
이것을 “검색 엔진은 RRF 상수를 변경할 수 없다”는 일반적인 제약으로 읽으면 안 된다. 여기서 확인한 것은 해당 게이트웨이 구현의 위임 정책이다. 엔진 자체 API가 제공하는 기능 범위와는 구분해야 한다.
결과 대신 호출 순서 기록
가짜 엔진에는 search와 keyword_search를 만들었다. 메서드를 시작할 때와 끝낼 때 이벤트를 남기고, 그 사이에 await asyncio.sleep(0)을 넣었다. 각 메서드는 미리 정한 설명용 문서만 반환한다.
가짜 엔진이 검색을 잘하는지는 이 실험의 관심사가 아니다. 라우팅 함수가 네이티브 위임과 개별 검색 중 어느 쪽을 선택하는지만 확인한다. 실제 소스의 검색 옵션, 결과 자료형, 퓨전 함수는 그대로 사용했다.
먼저 네이티브 하이브리드 지원을 켜고 전략을 RRF, 상수를 60으로 설정했다. 아래는 기록 배열의 이벤트를 발생 순서대로 옮긴 것이다.
search:hybrid:start
search:hybrid:end
게이트웨이는 search를 hybrid 모드로 한 번 호출했다. 이 호출에 전달된 옵션에는 검색 문장이 query_text로 들어 있었다. 다만 엔진 어댑터 메서드를 한 번 호출했다는 뜻이지, 실제 DB의 네트워크 요청이 한 번이라는 뜻은 아니다. 가짜 엔진으로는 실제 어댑터 내부 동작을 알 수 없다.
이제 다른 입력은 그대로 두고 rrf_k만 61로 바꿨다.
search:semantic:start
search:semantic:end
keyword_search:keyword:start
keyword_search:keyword:end
search의 모드가 semantic으로 바뀌었고, 그 호출이 끝난 뒤 keyword_search가 실행됐다. 이후 라우팅 함수는 두 결과 목록을 공용 퓨전 함수에 넘겼다. 요청한 rrf_k는 이 결합 단계에 전달된다.
대조 조건도 실행했다. 네이티브 지원을 끈 상태의 RRF와, 네이티브 지원을 켠 상태의 weighted 전략은 모두 개별 검색 경로를 탔다. 상수는 두 조건 모두 60이었다. 따라서 rrf_k=60만으로 위임을 결정하는 것은 아니다.
async라고 자동으로 병렬은 아니다
이벤트 기록에서 semantic의 종료가 keyword의 시작보다 앞섰다. 소스도 semantic 결과를 await한 다음 keyword 검색을 await한다. 이 호출부는 두 검색을 동시에 시작하지 않는다.
그렇다고 “상수를 바꾸니 검색 시간이 두 배가 됐다”고 말할 수는 없다. 이번 실험에서는 네트워크도 DB도 호출하지 않았고 지연 시간을 측정하지 않았다. 네이티브 어댑터 내부에서 어떤 작업을 하는지도 검증하지 않았다.
확인한 사실은 호출부의 실행 순서다. 실제 성능을 비교하려면 각 검색 시간, 결과 전송, 결합 비용을 따로 측정해야 한다. 이 글에서는 병렬화 코드를 추가하거나 성능 개선을 주장하지 않는다.
새 옵션을 만들 때 남는 값과 빠지는 값
실행 경로가 달라지면 옵션 전달도 다시 봐야 한다. 네이티브 위임에서는 기존 옵션을 복사하면서 검색 문장을 넣는다. 개별 검색 경로에서는 semantic용과 keyword용 옵션을 새로 만든다.
검토한 코드에서 top_k, filter, analyzer는 양쪽 검색 옵션에 들어간다. threshold는 semantic 쪽에 전달하고, expansion_terms는 keyword 쪽에 전달한다. 모든 설정을 양쪽에 복사하는 구조가 아니다.
설명용 확장어 목록을 넣어 실행했을 때도 semantic 옵션에는 빈 목록이, keyword 옵션에는 입력한 목록이 남았다. 이것은 전달 여부를 확인한 결과다. 실제 엔진이 확장어를 어떻게 해석하고 검색 결과를 바꾸는지는 검증하지 않았다.
rrf_k를 확인하는 위치도 주의해야 한다. 개별 검색용 옵션은 새로 생성되므로 자체 기본값이 보일 수 있다. 요청한 상수가 실제 결합에 쓰이는지 확인하려면 개별 검색 옵션뿐 아니라 퓨전 함수에 전달하는 인자까지 따라가야 한다.
튜닝 실험의 비교 기준
이번 실행에서 순위 개선 여부는 비교하지 않았다. 가짜 엔진이 반환하는 문서는 설명용으로 정해 뒀으므로 그 결과를 검색 품질로 평가할 이유도 없다.
실제 튜닝 실험을 설계한다면 질문, 데이터셋, 퓨전 전략, 상수와 함께 실행 경로를 기록하려 한다. RRF 상수의 효과만 보고 싶다면 동일한 후보 목록을 같은 퓨전 함수에 넣어 비교하는 실험부터 분리할 수 있다. 서비스 전체를 평가하는 실험에서는 경로 전환도 결과의 일부로 보고 측정해야 한다.
이때 네이티브와 공용 구현이 같은 RRF라는 이유만으로 점수까지 같다고 가정해서는 안 된다. 후보를 몇 개 가져오는지, 순위 번호를 어디서 시작하는지, 필터를 언제 적용하는지도 확인 대상이다. 이번 라우팅 실험은 두 구현의 수치적 동등성을 검증하지 않았다.
확인한 범위와 남은 검증
Python 3.13.13에서 실제 라우팅 함수의 본문을 수정하지 않고 추출해 실행했다. 표준 라이브러리만 사용하는 옵션, 결과, 퓨전 모듈도 같은 소스 스냅샷에서 불러왔다. 엔진 의존성은 호출 기록용 가짜 객체로 대체했고, hybrid 모드만 실행했다.
네이티브 지원 여부와 전략, 상수를 조합한 조건에서 호출 순서를 assertion으로 확인했다. 검색 문장과 확장어 전달도 검사했다. 앞의 독립 실행 코드도 별도로 실행했다.
이는 전체 패키지의 import, HTTP 요청 검증, 실제 엔진의 기능 지원, 운영 배포 상태를 확인한 결과가 아니다. 가짜 객체가 반환한 결과를 결합할 수 있다는 사실도 실제 검색 엔진의 결과 품질을 보증하지 않는다.
RRF 상수는 수식에 들어가는 숫자다. 하지만 그 숫자를 읽는 코드가 수식뿐인 것은 아니다. 검색 결과가 달라졌을 때는 점수 계산부터 보기 전에, 같은 검색 경로를 비교하고 있는지 확인할 필요가 있다.
References
- Elasticsearch: Reciprocal rank fusion: RRF의 일반적인 원리와 Elasticsearch API. 이 글의 게이트웨이 위임 정책과는 별도다.
- Python: Coroutines and tasks: 코루틴,
await, 태스크의 실행 방식
