RAG 검색, 한 번에 안 끝나길래 두 번 돌렸다 - rerank 뜯어보기
서버 IP를 물어봤는데 점검 일정이 올라온다
회사 내부 문서를 RAG로 검색하는 시스템을 운영하고 있습니다.
"물류 서버 IP 알려줘"라고 질문하면 관련 문서를 찾아서 답변해주는 구조거든요.
그런데 검색 결과를 보면, "서버"라는 단어가 들어간 문서가 전부 올라옵니다.
서버 IP 문서, 서버 점검 일정, 서버 장애 복구 절차까지.
질문은 IP 주소를 묻고 있는데, 검색 엔진은 "서버"만 보고 다 가져오는 거죠.
임베딩 검색(벡터 유사도)만으로는 여기까지가 한계였습니다.
빠르지만 대충인 1단계, 느리지만 정확한 2단계
임베딩 검색의 원리는 간단합니다.
모든 문서를 숫자 목록(벡터)으로 바꿔놓고, 질문도 벡터로 바꿔서 "숫자가 비슷한 것"을 찾는 겁니다.
도서관에 책이 10만 권 있다고 칩시다.
"서버 IP"라고 물어보면, 제목이나 내용이 비슷해 보이는 책 20권을 일단 빠르게 뽑아옵니다.
빠르지만 대충이에요.
"서버"가 들어간 건 전부 후보에 올라오니까요.
이 방식을 Bi-Encoder라고 부릅니다. Bi는 "둘"이라는 뜻인데, 인코더가 2개라서 그래요. 하나는 질문을 벡터로, 다른 하나는 문서를 벡터로 바꿉니다. 두 벡터를 각각 독립적으로 만든 뒤 유사도를 비교하는 구조거든요.
빠른 이유가 있습니다.
문서 쪽 벡터는 미리 전부 계산해놓을 수 있어요. 질문이 들어오면 질문만 벡터로 바꿔서 이미 저장된 문서 벡터들과 비교하면 되니까, 10만 건이든 100만 건이든 금방 후보를 뽑을 수 있습니다.
문제는 정확도입니다.
"서버 IP"와 "서버 점검 일정"이 벡터상으로는 꽤 가깝거든요. 단어가 비슷하니까 숫자도 비슷하게 나오는 겁니다.

그래서 2단계가 필요합니다.
1단계에서 뽑아온 후보를 한 건씩 꼼꼼히 다시 보는 거예요.
이때 쓰는 방식이 Cross-Encoder입니다. Bi-Encoder가 질문과 문서를 따로따로 벡터로 만든 거라면, Cross-Encoder는 질문과 문서를 나란히 붙여서 하나의 모델에 함께 넣습니다. "이 문서가 진짜 이 질문에 답하고 있나?"를 통째로 판단하는 거죠.
Cross-Encoder는 0.0에서 1.0 사이의 점수를 매깁니다.
질문: "물류 서버 IP 알려줘"
문서A: "물류 서버의 IP 주소는 10.1.2.3이며..." → 0.934
문서B: "물류 시스템 장애 복구 방법은..." → 0.183
문서C: "회의실 예약 시스템 안내..." → 0.003
단어가 비슷한지가 아니라, 문서가 질문의 답이 되는지를 판단합니다. 이 판단 능력은 수백만 개의 질문-답변 쌍을 학습해서 만들어진 거예요. 사람이 기준을 정해준 게 아니라, AI가 패턴을 학습한 겁니다.
정확하지만 느립니다.
질문+문서를 한 쌍씩 넣어야 하니까, 후보 하나마다 새로 계산해야 하거든요. 10만 건 전체를 Cross-Encoder로 돌리면 시간이 너무 걸립니다.
결국 정답은 조합입니다.
1단계(Bi-Encoder)로 빠르게 후보를 뽑고, 2단계(Cross-Encoder)로 정밀하게 순서를 다시 매기는 것. 이 2단계 재정렬을 리랭크(Re-ranking)라고 부릅니다.

9개 질문으로 점수 분포를 확인했다
리랭크 모델로는 BGE-Reranker-v2-m3을 선택했습니다.
BGE는 BAAI General Embedding의 약자로, 베이징 인공지능 연구원에서 만든 오픈소스 모델이에요. Hugging Face 벤치마크 상위권에 꾸준히 올라가 있고, 다국어 지원도 괜찮은 편이거든요. 무엇보다 오픈소스라서 자체 서버에 올려 쓸 수 있다는 게 선택 이유였습니다.
모델을 붙인 뒤에는 실제 데이터로 점수 분포를 확인해야 합니다. 같은 모델이라도 데이터 특성에 따라 점수 범위가 달라지거든요. 이메일 중심 데이터와 기술 문서 중심 데이터는 점수 분포가 다릅니다.
Python 스크립트로 검색 파이프라인을 직접 호출했습니다. 채팅 UI를 거치지 않고, LangGraph의 검색 그래프만 따로 꺼내서 실행한 거예요.
from rag.graph import build_search_graph
search_graph = build_search_graph()
state = search_graph.invoke({"query": "물류 서버 IP 알려줘"})
ranked = state.get("ranked_results", [])
top_score = max(r.get("rerank_score", 0) for r in ranked)질문 9개를 세 카테고리(답 있음 / 애매함 / 답 없음)로 나눠서 반복 실행했습니다.
결과를 보니 패턴이 명확하더라고요.
카테고리 | top_rerank 범위 | 예시 |
|---|---|---|
Good (답 있음) | 0.790 ~ 0.934 | 물류 서버 IP, 주문 시스템 장애 이력 |
애매한 구간 | 0.033 ~ 0.132 | 보안 이슈, 장애 대응 절차 |
답 없음 | 0.000 ~ 0.014 | 삼성전자 주가, 피자 맛집 |
0.5를 기준으로 Correct와 Ambiguous가 깔끔하게 나뉘었습니다. 답 없는 질문("삼성전자 주가", "피자 맛집")은 0.01 이하로 떨어지면서 chitchat으로 분류됐고요. 이 점수 경계가 CRAG(Corrective RAG)에서 "재검색할지 말지"를 판단하는 임계값이 됩니다.

관계도가 답했는데 텍스트 점수는 0.001이었다
실험하면서 예상 못 한 함정이 하나 나왔습니다.
"ERP 담당자"를 질문하면, 지식 관계도(Knowledge Graph) 도구가 정확히 답을 찾았거든요. 담당자 이름, 연락처까지 바로 나왔습니다. 그런데 텍스트 검색 쪽 리랭크 점수는 0.008이었습니다.
"결제 시스템 장애 이메일"도 마찬가지로 0.001. "DNS 의존관계"는 0.003.
전부 관계도 도구가 제대로 답을 찾았는데, 리랭크 점수만 보면 Incorrect 판정이에요.
이 상태에서 CRAG가 "점수 낮으니까 재검색하자"고 판단하면 오히려 문제가 됩니다. 이미 답이 나와 있는 질문에 불필요한 웹 검색을 추가로 돌리게 되는 거거든요.
결국 CRAG 판단 로직을 수정했습니다.
Correct: top_rerank ≥ 0.5 OR 관계도 결과 있음
Ambiguous: 0.02 ≤ top_rerank < 0.5
Incorrect: top_rerank < 0.02 AND 관계도 결과도 없음
"OR 관계도 결과 있음" 한 줄 추가한 건데, 이게 없으면 관계도가 찾은 정답을 버리고 다시 찾으러 가는 상황이 벌어집니다.

마무리
RAG 검색에서 1단계 임베딩은 속도 담당, 2단계 리랭크는 정확도 담당입니다.
둘 다 있어야 "빠르면서 정확한" 검색이 가능하더라고요.
실제 데이터로 실험해보면 점수 분포가 명확하게 나뉘어서,
"이 문서가 답이 맞는지"를 자동으로 판단할 수 있는 기준이 잡힙니다.
다만 텍스트 검색 외에 지식 관계도 같은 다른 검색 수단이 있다면,
리랭크 점수만으로 판단하면 오히려 정답을 버리는 경우가 생깁니다.
임계값을 잡을 때 전체 파이프라인을 함께 고려하는 걸 추천합니다.