jongkwan.dev
개발 · Essay №092

RAG 재순위: 크로스 인코더

바이인코더가 추린 후보를 크로스 인코더로 다시 정렬해 검색 정밀도를 높이는 2단계 재순위 전략

이종관2026년 5월 27일15 min read
Contents

바이인코더가 빠르게 추린 후보를 크로스 인코더가 질의와 함께 다시 읽어 순위를 바로잡는 2단계 검색 전략입니다.

바이인코더만으로 부족한 이유

검색 강화 생성(Retrieval-Augmented Generation, RAG)은 외부 문서를 검색해 그 근거로 답을 만듭니다. 이때 검색 품질이 답의 품질을 좌우합니다. 가장 널리 쓰이는 1차 검색기는 바이인코더(bi-encoder)입니다.

바이인코더는 질의와 패시지를 각각 따로 벡터로 만든 뒤, 두 벡터의 내적으로 유사도를 잽니다. 패시지 벡터는 질의가 오기 전에 미리 계산해 둘 수 있어 검색이 빠릅니다. 대신 질의와 패시지가 서로를 참조하지 못해, 단어만 겹치는 패시지를 과대평가하기 쉽습니다.

진짜 문제는 정밀도입니다. 정답 패시지가 후보 안에 들어와도(재현율 확보), 상위 몇 건 안으로 올라오지 못하면 LLM이 보지 못합니다. 후보 안에서 정말 관련 있는 것을 골라 위로 올리는 별도 단계가 필요합니다.

크로스 인코더의 쌍 입력

크로스 인코더(cross-encoder)는 질의와 패시지를 하나의 입력으로 합쳐 넣습니다. BERT류 인코더의 형식을 빌리면 [CLS] 질의 토큰들 [SEP] 패시지 토큰들 [SEP] 모양입니다. 여기서 [CLS]는 입력 전체를 대표하는 특수 토큰이고, [SEP]는 두 텍스트의 경계를 표시하는 토큰입니다.

질의 쪽에는 세그먼트 0을, 패시지 쪽에는 세그먼트 1을 부여합니다. 셀프 어텐션 과정에서 질의 토큰과 패시지 토큰이 서로를 직접 참조하므로, 질의의 어느 부분이 패시지의 어느 부분과 대응하는지 세밀하게 따집니다. 마지막으로 [CLS] 벡터를 선형 변환하면 하나의 관련도 점수가 나옵니다.

이 토큰 수준 상호작용 덕분에 크로스 인코더는 바이인코더보다 관련도 판정이 정밀합니다. 대신 비용이 큽니다. 패시지 벡터를 미리 만들어 둘 수 없고, 질의가 도착한 뒤에야 쌍을 만들어 인코딩을 시작합니다. 패시지가 백만 건이면 질의 한 건마다 백만 번 인코딩해야 해서, 실시간 서비스에서는 감당하기 어려운 지연(latency)이 생깁니다.

구분바이인코더크로스 인코더
입력질의·패시지 각각질의-패시지 쌍 함께
인코딩 시점패시지는 오프라인 사전 계산질의 도착 후 실시간
상호작용없음(내적 유사도)토큰 수준 셀프 어텐션
정밀도상대적으로 낮음높음
비용낮음높음

두 모델은 빠름과 정밀함을 한쪽씩 가집니다. 그래서 둘 중 하나만 고르기보다, 순서대로 이어 붙이는 편이 낫습니다.

2단계 재순위 파이프라인

재순위(re-ranking)는 빠른 검색기로 후보를 좁힌 뒤, 정밀한 모델로 그 후보의 순위를 다시 매기는 단계입니다. 전체 흐름은 아래와 같습니다.

  • 1단계: 바이인코더가 근사 최근접 이웃(Approximate Nearest Neighbor) 인덱스에서 후보 수백~수천 건을 낮은 지연으로 가져옵니다.
  • 2단계: 크로스 인코더가 후보 각각을 질의와 함께 입력으로 받아 관련도 점수를 매깁니다.
  • 후보가 500건이면 인코딩도 500번뿐이라, 코퍼스 전체 백만 건을 상대하는 비용과는 비교가 되지 않습니다.

앞 단계는 재현율을, 뒤 단계는 정밀도를 맡습니다. 정답이 후보에 아예 없으면 검색 단계를, 후보엔 있는데 상위로 못 오면 재순위 단계를 손봐야 합니다.

코드로 보는 순위 교정

바이인코더로 후보 5건을 뽑고, 크로스 인코더로 다시 정렬하는 예입니다. 모델은 sentence-transformers의 공개 모델을 씁니다.

python
import numpy as np
from sentence_transformers import SentenceTransformer, CrossEncoder
 
# 바이인코더: 질의와 패시지를 각각 벡터로 만든다
bi_encoder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
 
query = "카페인이 수면에 미치는 영향"
passages = [
    "카페인은 아데노신 수용체를 차단하여 졸음을 억제한다.",
    "커피 원두의 로스팅 온도는 풍미에 큰 영향을 준다.",
    "수면 부족은 집중력 저하와 면역 기능 약화를 유발한다.",
    "카페인의 반감기는 약 5시간이므로 오후 섭취가 취침에 영향을 줄 수 있다.",
    "에스프레소 한 잔의 카페인 함량은 약 63mg이다.",
    "깊은 수면 단계에서 성장 호르몬이 분비된다.",
    "카페인은 세계에서 가장 널리 소비되는 정신활성 물질이다.",
    "취침 전 블루라이트 노출은 멜라토닌 분비를 억제한다.",
]
 
q_vec = bi_encoder.encode(query)
p_vecs = bi_encoder.encode(passages)
scores_bi = p_vecs @ q_vec                    # 내적 유사도
top_indices = np.argsort(scores_bi)[::-1][:5] # 상위 5건
 
# 크로스 인코더: 질의-패시지 쌍을 함께 넣어 점수를 매긴다
cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
candidates = [passages[i] for i in top_indices]
pairs = [[query, c] for c in candidates]
scores_ce = cross_encoder.predict(pairs)
 
reranked = sorted(zip(top_indices, scores_ce), key=lambda x: x[1], reverse=True)

실행하면 예를 들어 다음과 같은 순서가 나옵니다.

text
바이인코더 상위 5건 (내적 유사도 순)
  1. 카페인의 반감기는 약 5시간이므로 오후 섭취가 취침에 영향을 줄 수 있다.
  2. 카페인은 세계에서 가장 널리 소비되는 정신활성 물질이다.
  3. 카페인은 아데노신 수용체를 차단하여 졸음을 억제한다.
  4. 에스프레소 한 잔의 카페인 함량은 약 63mg이다.
  5. 수면 부족은 집중력 저하와 면역 기능 약화를 유발한다.
 
크로스 인코더 재순위 후
  1. 카페인은 아데노신 수용체를 차단하여 졸음을 억제한다.
  2. 카페인의 반감기는 약 5시간이므로 오후 섭취가 취침에 영향을 줄 수 있다.
  3. 수면 부족은 집중력 저하와 면역 기능 약화를 유발한다.
  4. 카페인은 세계에서 가장 널리 소비되는 정신활성 물질이다.
  5. 에스프레소 한 잔의 카페인 함량은 약 63mg이다.

바이인코더는 '카페인' 단어가 겹친다는 이유로 '정신활성 물질'과 '에스프레소 함량'을 위로 올렸습니다. 정작 수면 메커니즘을 설명하는 '아데노신 수용체 차단'은 3위에 머물렀습니다. 크로스 인코더는 이 패시지를 1위로 끌어올리고, 수면과 무관한 항목을 아래로 내렸습니다. 핵심은 같은 후보 집합을 두 모델이 다르게 정렬한다는 점입니다.

후보 수와 하이브리드 검색

정밀도와 비용의 균형은 바이인코더가 넘기는 후보 수로 조절합니다. 후보를 늘리면 크로스 인코더가 볼 범위가 넓어져 정밀도가 오르지만, 인코딩 횟수가 늘어 지연이 커집니다. 후보를 줄이면 빨라지지만, 바이인코더가 놓친 패시지는 크로스 인코더도 보지 못합니다.

1차 검색을 더 든든히 하려고 어휘 검색과 밀집 검색을 함께 쓰기도 합니다. 단어 빈도 기반 알고리즘 BM25와 밀집 벡터 검색이 각각 후보를 만들고, 둘을 합쳐 중복을 제거합니다. 그 합집합에 크로스 인코더를 적용하면, 두 검색이 잡은 서로 다른 후보를 한 번에 재순위합니다.

이 구조에서는 단계별 분업이 분명합니다. BM25와 밀집 검색의 합집합이 재현율을 끌어올리고, 크로스 인코더가 그 위에서 정밀도를 맡습니다.

지식 증류로 경량화

크로스 인코더를 서빙 경로에 두면 그만큼 지연이 늘어납니다. 지식 증류(knowledge distillation)는 이 비용을 학습 단계로 옮기는 전략입니다. 정밀하지만 느린 크로스 인코더를 교사로, 빠르지만 덜 정밀한 바이인코더를 학생으로 두는 교사-학생(teacher-student) 구조입니다.

교사가 질의-패시지 쌍에 매긴 부드러운 점수, 곧 0과 1 사이의 연속 확률을 학생이 근사하도록 학습합니다. 관련·무관 이진 라벨만 쓰면 학생은 경계 근처의 미묘한 차이를 배우기 어렵습니다. 0.92 관련과 0.71 관련 같은 연속 점수는 더 풍부한 기울기 신호를 주어, 학생이 미세한 등급 차이까지 익히게 합니다.

학생의 손실에는 교사 점수와의 KL 발산(Kullback-Leibler divergence)이나 평균 제곱 오차(MSE)를 씁니다. 원래의 대조 손실과 증류 손실을 가중 합산해 함께 최적화하기도 합니다. 학습이 끝나면 서빙에서는 교사가 필요 없습니다. 학생 바이인코더만으로 검색과 재순위를 처리해, 사전 인코딩 이점은 지키면서 크로스 인코더에 가까운 순위 품질을 얻습니다.

증류로 무거운 모델에 가까운 품질을 더 낮은 비용에서 얻었다는 보고가 있지만, 구체 수치는 모델과 데이터셋마다 달라 그대로 일반화하기는 어렵습니다.

정리

크로스 인코더는 질의와 패시지를 함께 읽어 토큰 수준으로 관련도를 따지므로, 단어 겹침에 흔들리는 바이인코더보다 정밀합니다. 대신 후보마다 인코딩을 다시 해야 해서 코퍼스 전체에 직접 쓰기는 어렵습니다. 그래서 바이인코더로 후보를 빠르게 좁히고 크로스 인코더로 재순위하는 2단계 구조가 널리 쓰입니다. 정밀도와 지연의 균형은 후보 수로 조절하고, 서빙 비용이 부담이면 지식 증류로 그 판단을 가벼운 모델에 옮길 수 있습니다. 파이프라인을 개선할 때는 재현율과 정밀도 중 어느 쪽이 부족한지부터 보고 손볼 단계를 고르면 됩니다.