검색이 답을 결정한다 — 실패한 아이디어 넷과, GPU 1.3GB를 받아 간 리랭커
제 보험 약관과 계약서로 RAG를 시험하자 정답이 자꾸 아래로 밀렸습니다. 다섯 가지를 시도했고, 넷은 숫자가 거절했습니다. 남은 하나는 CPU에서 15초, GPU에서 0.17초였습니다.
이 글의 독자: 지난 글에서 이어집니다. Hit@k, MRR 같은 용어는 지난 글 Step 0에 있습니다.
들어가며 — 진짜 문서를 넣자 떨어진 숫자
지난 글의 결론은 “정답 조각을 받으면 4B 모델은 맞게 답한다, 그러니 검색이 답을 결정한다”였습니다.
그래서 이번엔 공개 문서가 아니라 제가 실제로 물어볼 문서를 넣었습니다. 한국어 PDF 4개입니다. 암보험 약관과 상품 요약서, 자동차 인포테인먼트 설명서, 개인정보를 가린 매매 계약서. 청크 469개, 질문 16개를 추가했습니다.
| Hit@5 | MRR | |
|---|---|---|
| 공개 문서 (36문항) | 33/36 | 0.78 |
| 내 문서 (16문항) | 13/16 | 0.66 |
MRR 0.66은 “정답이 평균적으로 1~2등 사이 어딘가”라는 뜻입니다. 정답을 찾긴 하는데 자꾸 아래에 있습니다. 프롬프트에 넣을 수 있는 근거의 양(토큰 예산)은 정해져 있어서, 아래로 밀린 정답은 모델에게 도착하지 못합니다.
이번 글의 기준 숫자는 이것입니다.
전달(delivered): 52개 질문 중, 정답 조각이 실제 프롬프트 예산 안에 들어간 질문 수. 시작은 46 / 52.
Step 0 — 문서를 넣다가 먼저 발견한 것 3개
검색을 고치기 전에, 문서 자체에서 문제가 나왔습니다.
1. 제�11�조. 약관 PDF에서 뽑은 글에 � 문자가 844개 있었습니다. PDF의 특정 공백 글리프를 Tika가 글자로 바꾸지 못한 겁니다. “제11조”를 검색해도 안 걸립니다. 정리 단계에서 공백으로 바꿨습니다.
2. 29글자짜리 12MB 설명서. 자동차 설명서 하나는 전부 이미지였습니다. 글자를 29개 뽑았고, 그대로 “색인 완료”가 됐습니다. 검색에는 영원히 안 걸리는데 사용자는 모릅니다. 이제는 100KB 이상인데 글자가 200자 미만이면 “이미지로만 된 파일이라 읽을 수 없다”고 실패로 표시합니다.
3. 가렸는데 안 가려진 계약서. “개인정보를 가린” 계약서에서 가렸던 개인 식별 번호가 그대로 추출됐습니다. 흰 사각형으로 덮었을 뿐, 그 아래 텍스트 층은 살아 있었던 겁니다. 문서를 지우고, 제대로 다시 가리고, 패턴 개수로 남은 게 없는지 확인했습니다.
PDF에서 검은/흰 상자로 덮는 건 가리기가 아닙니다. 텍스트를 실제로 지우는 도구로 처리해야 합니다. 화면에 안 보여도 기계는 읽습니다.
Step 1 — 오답의 모양부터 보기
오답을 읽어 보니 대부분 같은 모양이었습니다. 문서는 맞게 찾았는데, 그 문서 안에서 엉뚱한 조각을 골랐습니다. 그리고 약관 쪽은 질문과 문서의 어휘가 달랐습니다. 저는 “세금은 언제 기준이야?”라고 묻는데, 계약서는 “제세공과금 … 인도일”이라고 씁니다.
여기서 원칙을 하나 세웠습니다.
검색 변경은 게이트웨이 코드를 고치기 전에 오프라인으로 먼저 시뮬레이션한다. 평가 스크립트가 “이 방식이면 프롬프트에 무엇이 들어갔을지”를 재현하고, 숫자가 좋아질 때만 구현한다.
Step 2 — 실패 1: 이웃 조각 붙이기
아이디어: 문서는 맞게 찾으니까, 찾은 조각의 앞뒤 조각까지 같이 넣으면 정답이 딸려 들어오지 않을까?
시뮬레이션 결과, 상위 5개에 앞뒤 1개씩을 붙이면 전달된 답이 (공개 33 + 내 문서 13) → (23 + 9)로 줄었습니다. 어떤 예산으로 바꿔도 줄었습니다.
이유는 오답을 다시 보니 명확했습니다. 놓친 정답은 찾은 조각의 바로 옆에 있지 않았습니다. 이웃을 붙이면 관계없는 조각이 예산을 먹고, 원래 들어가던 정답 조각이 밀려납니다. 거절.
Step 3 — 실패 2, 3, 4: 질문을 고쳐 쓰기
어휘가 안 맞는 문제라면, 질문을 문서의 말투로 바꿔서 검색하면 되지 않을까? 유명한 방법이 여럿 있습니다.
| 방법 | 하는 일 |
|---|---|
| Rewrite | 모델이 질문을 검색하기 좋은 문장으로 다시 씀 |
| HyDE | 모델이 “있을 법한 답”을 먼저 지어내고, 그 가짜 답으로 검색 |
| Fusion (RRF) | 원래 질문과 고친 질문의 검색 결과를 순위로 합침 |
셋 다 오프라인으로 돌렸습니다. 결과는 전부 원래 질문보다 나빴습니다. 전달 46 → 42~45.
흥미로운 건 실패의 모양이었습니다. 각 방법은 놓쳤던 정답 몇 개를 새로 찾아오는 만큼, 원래 찾던 정답을 상위 5개 밖으로 밀어냈습니다. 합계는 늘 손해였습니다. 거절.
다만 하나는 살아남았습니다. 후속 질문입니다. “그럼 그건 얼마야?”처럼 앞 대화를 봐야 뜻이 통하는 질문은 그 자체로 검색이 안 됩니다. 후속 질문 16개로 따로 쟀습니다.
| 검색에 쓴 문장 | 찾음 |
|---|---|
| 마지막 질문 그대로 | 11 / 16 |
| 모델이 앞 대화를 보고 독립된 질문으로 고쳐 씀 | 13 / 16 |
그래서 후속 질문일 때만 고쳐 쓴 문장으로 검색합니다. 고쳐 쓴 문장은 검색에만 쓰고, 모델에게는 사용자가 실제로 한 질문을 그대로 보여 줍니다. 2초 안에 안 되면 원래 질문으로 검색합니다.
Step 4 — 남은 하나: 리랭커
마지막 후보는 cross-encoder 리랭커였습니다. 지금까지의 검색과 원리가 다릅니다.
임베딩 검색 (bi-encoder): 질문과 조각을 따로따로 벡터 하나로 압축한 뒤, 두 벡터가 가까운지 봅니다. 조각의 벡터는 미리 계산해 둘 수 있어서 수천 개를 순식간에 훑습니다. 대신 질문과 조각이 서로를 한 번도 보지 못합니다. “보장하지 않는다”와 “보장한다”의 차이 같은 세부가 압축 과정에서 뭉개집니다.
리랭커 (cross-encoder): 질문과 조각을 한 입력으로 같이 읽고 “이 조각이 이 질문에 답하는가”를 점수로 냅니다. 질문의 모든 단어가 조각의 모든 단어를 볼 수 있어서 훨씬 정확합니다. 대신 미리 계산할 수 없고, 질문이 올 때마다 조각 수만큼 돌려야 합니다.
그래서 둘을 겹쳐 씁니다.
\[\underbrace{\text{전체 청크}}_{\text{수천 개}} \xrightarrow{\text{임베딩 검색}} \underbrace{\text{상위 30개}}_{\text{빠르고 거칠게}} \xrightarrow{\text{리랭커}} \underbrace{\text{상위 몇 개}}_{\text{느리고 정확하게}} \rightarrow \text{프롬프트}\]bge-reranker-v2-m3를 CPU에서 오프라인으로 돌렸습니다.
| 전달 | 내 문서 MRR | 잃은 정답 | |
|---|---|---|---|
| 임베딩 검색만 | 46 / 52 | 0.66 | — |
| + 리랭커 (상위 20) | 49 / 52 | 0.84 | 0 |
다섯 시도 중 처음으로, 아무것도 잃지 않고 좋아졌습니다. 그런데 하나가 문제였습니다.
질문당 15초. CPU에서 조각 20개를 하나씩 읽으려니 그만큼 걸렸습니다. 첫 글자까지 15초 걸리는 채팅은 아무도 안 씁니다.
Step 5 — GPU에 자리가 있는가 (결정)
GPU에서 돌리면 빨라질 겁니다. 문제는 GPU가 vLLM의 것이라는 점이었습니다. 이 프로젝트의 첫 규칙 중 하나가 “GPU는 한 번에 한 엔진만”이었습니다.
계산부터 했습니다.
| 항목 | VRAM |
|---|---|
| 카드 전체 | 7.6 GiB (실제 사용 가능) |
vLLM 예약 (0.75) | 5.7 GiB |
| 리랭커 (두 번째 작은 vLLM, pooling 모드) | 1.3 GB |
| 남는 것 | 약 0.27 GB |
딱 들어갑니다. 단, 조건이 하나 붙습니다. 첫 글에서 우분투 데스크톱 화면이 1.2GB를 먹었습니다. 리랭커 자리는 바로 그 데스크톱 자리입니다. 노드에 모니터를 연결해 데스크톱으로 로그인하는 순간 둘 중 하나가 시작에 실패합니다.
다른 길도 쟀습니다. vLLM을 0.70으로 줄여서 데스크톱과 리랭커가 둘 다 들어가게 하면? RAG 부하에서 처리량이 43% 떨어졌습니다. KV cache가 줄어 동시에 3~4개 요청밖에 못 들고 있었기 때문입니다.
그래서 결정은 이렇게 했습니다(ADR로 기록했습니다).
- 리랭커는 두 번째 작은 vLLM 컨테이너로 GPU에 올린다.
- 채팅 vLLM은 0.75를 유지하고, 노드는 데스크톱 없이 운영한다. 데스크톱이 필요해지면 vLLM을 줄이는 대신 리랭커를 끈다.
- “GPU는 한 엔진만”은 생성 엔진에 대한 규칙으로 좁힌다. 크기가 고정된 작은 보조 모델은 측정해서 들어가면 같이 쓸 수 있다.
- 리랭커가 실패하거나 늦으면 임베딩 순서 그대로 답한다. 리랭커는 답을 좋게 만들 뿐, 막지는 않는다.
GPU에서의 결과입니다.
| 지연 | 전달 | |
|---|---|---|
| CPU, 상위 20 | 15 s | 49 / 52 |
| GPU, 상위 30 | 0.17 s (부하 시 0.33 s) | 50 / 52 |
90배 빨라졌고, 후보를 30개로 늘릴 여유까지 생겼습니다.
운영에서 하나 더 조정했습니다. 리랭커는 요청을 사실상 하나씩 처리합니다(건당 약 0.22초). 동시 16명이 RAG를 쓰면 줄이 길어집니다. 그래서 게이트웨이가 리랭커에 동시 4개, 대기 0.5초까지만 허용하고, 그걸 넘으면 기다리지 않고 임베딩 순서로 답합니다. 몰릴 때는 빠른 차선이 느린 최선보다 낫습니다.
Step 6 — 못 찾았을 때 어떻게 말하는가
검색이 아무리 좋아져도 못 찾는 질문은 남습니다. 그때 모델이 어떻게 행동하는지도 쟀습니다. 시스템 프롬프트(근거 사용 규칙)를 여러 버전으로 바꿔 가며, 실제 운영 온도로 같은 질문을 여러 번 샘플링했습니다. 한 번씩만 물어보면 운 좋은 답 하나로 판단하게 됩니다.
| 이전 프롬프트 | 새 프롬프트 | |
|---|---|---|
| 한국어 질문에, 못 찾았다는 말도 한국어로 | 9 / 20 | 17 / 20 |
| 근거에 없는 세부를 지어냄 | 8 / 20 | 3 / 20 |
| 답할 수 있는데 거절 | — | 0 / 11 |
바꾼 내용은 이렇습니다. “근거 중 일부는 질문과 관계없을 수 있다. 답이 되는 것만 써라. 아무것도 덧붙이지 마라. 못 찾았다는 말도 질문의 언어로 하라.”
지어내기가 줄었지 사라진 건 아닙니다. 나머지는 검색이 더 좋아지거나 모델이 커져야 풀립니다.
Step 7 — 하지 않기로 한 것: 키워드 검색
남은 오답 두 개에 키워드 검색(hybrid search)을 붙이면 될까 싶어 봤습니다. 하나는 한국어 질문에 영어 RFC가 정답이었고, 다른 하나는 아까의 “세금 … 기준 시점” vs “제세공과금 … 인도일”이었습니다. 겹치는 단어가 하나도 없습니다. 키워드 검색이 닿을 수 없는 오답입니다. 건너뛰었습니다.
정리 — 오늘 얻은 것
| 시작 | 끝 | |
|---|---|---|
| Hit@5 (52문항) | 0.88 | 0.96 |
| MRR | 0.745 | 0.830 |
| 후속 질문 찾음 | 11 / 16 | 13 / 16 |
| 못 찾았을 때 질문 언어로 답함 | 9 / 20 | 17 / 20 |
남긴 것 4개: 청크 정리, GPU 리랭커, 후속 질문 고쳐 쓰기, 근거 사용 프롬프트.
숫자가 거절한 것 5개: 이웃 조각 붙이기, 단일 질문 고쳐 쓰기(Rewrite / HyDE / Fusion), CPU 리랭커, vLLM을 줄여 리랭커 자리 만들기.
배운 것:
- 유명한 기법이라고 내 데이터에서 좋아지는 건 아니다. 오프라인 시뮬레이션 먼저.
- “찾아온 것”만 보지 말고 “밀려난 것”도 셀 것. 대부분의 실패는 거기 숨어 있었다.
- 규칙(“GPU는 한 엔진만”)은 측정으로 좁힐 수 있다. 대신 조건(데스크톱 없음)을 문서로 남길 것.
다음 글
여기까지 오니 걱정이 하나 생겼습니다. 이 노드에는 백업이 하나도 없었습니다. 알림도 없었습니다. 제가 직장에 있는 동안 무언가 죽으면, 저는 집에 와서야 압니다.
다음 글은 아무도 안 볼 때도 몇 주씩 돌아가게 만든 이야기입니다. 백업을 클라우드에서 복구해 보고, 일부러 장애를 내서 휴대폰으로 알림이 오는지 보고, 재부팅을 해 봤습니다. 그 재부팅에서 KV cache가 절반으로 줄어드는 이상한 현상을 만났습니다.