장르: 개념 심화. 앞선 구축 기록을 독자가 한 줄씩 되물으며 파고든 문답을 정리한 글입니다. "왜 그렇게 만들었나"를 코드까지 열어 확인했습니다.


1. 하이브리드 검색의 세 부품 — trigram, BM25, RRF

검색 한 번은 세 요소가 맞물려 돌아갑니다. 키워드 갈래를 떠받치는 게 이 셋입니다.

1-1. FTS5 trigram — 한국어 조사를 우회하는 토크나이저

FTS5는 SQLite에 내장된 전문 검색 확장입니다. LIKE '%연차%'가 모든 행을 훑는 풀스캔인 반면, FTS5는 역색인(어떤 토큰이 어느 문서에 있는지 미리 뒤집어 저장)으로 질의어의 목록만 바로 꺼냅니다.

역색인을 만들려면 텍스트를 토큰으로 쪼개야 하고, 그 방식을 정하는 게 토크나이저입니다. 여기서 기본값(unicode61)이 아니라 trigram을 골랐습니다. 한국어가 교착어이기 때문입니다.

토크나이저 "연차휴가는" 을 쪼개면
unicode61(기본) ["연차휴가는"] — 공백까지 통째로 하나
trigram ["연차휴", "차휴가", "휴가는"] — 연속 3글자 슬라이딩

문서에는 연차휴가는, 질의는 연차휴가. unicode61은 조사가 붙은 통짜 토큰으로 저장해서 질의어와 문자열이 정확히 일치하지 않아 매칭에 실패합니다. trigram은 3글자 창을 밀며 쪼개니 질의의 연차휴, 차휴가가 문서 토큰에 들어 있어 부분 문자열로 잡힙니다. 형태소 분석기 없이 순수 문자 단위로 조사 문제를 우회하는 값싼 방법입니다.

대가는 "3글자 최소"입니다. 2자 이하 질의는 trigram 토큰이 아예 안 생겨 키워드 갈래가 침묵하고 벡터 검색에만 의존합니다.

한국어 검색에서 토크나이저 선택은 성능 튜닝이 아니라 매칭 여부 자체를 가릅니다. unicode61이었다면 조사 붙은 문서 대부분이 안 잡혀, 뒤에 아무리 좋은 랭킹을 얹어도 키워드 갈래가 사실상 죽습니다.

1-2. BM25 — 희귀·짧음·빈도로 매기는 점수

trigram이 후보를 찾아주면, BM25는 그 후보를 순위 매깁니다. 세 신호를 결합합니다.

  • TF — 이 문서에 질의어가 몇 번 나오나(많을수록 ↑). 단 포화가 있어 10번 → 20번이라고 점수가 2배가 되진 않습니다. 키워드 도배로 점수를 무한정 못 올리게 막습니다.
  • IDF — 이 단어가 코퍼스 전체에서 얼마나 희귀한가(희귀할수록 ↑). 규정은 거의 모든 문서에 나와 변별력이 없고, 경조휴가는 등장하면 강한 신호입니다.
  • 길이 정규화 — 긴 문서는 우연히 걸릴 확률이 높으니 할인. 짧은 청크에서 걸리면 그게 주제일 가능성이 큽니다.

외울 필요는 없고 "희귀한 단어가, 짧은 문서에, 여러 번(단 포화) 나오면 높은 점수"로 족합니다. 참고로 SQLite의 bm25()는 관례상 값이 낮을수록(더 음수일수록) 관련도가 높지만, 다음 단계에서 순위로 변환하니 이 부호 문제는 사라집니다.

1-3. RRF — 스케일이 다른 두 결과를 순위로만 합친다

이제 갈래가 둘입니다. 벡터 검색은 코사인 거리(0.26, 0.34 …), BM25는 BM25 점수(-8.2, -5.1 …). 단위가 전혀 달라 직접 더할 수 없습니다. 정규화해서 섞는 방법도 있지만 질의마다 점수 분포가 출렁여 기준이 흔들립니다.

RRF(Reciprocal Rank Fusion)는 점수를 버리고 등수만 씁니다.

RRF(문서) = 1/(60 + 벡터순위) + 1/(60 + 키워드순위)

각 문서에 대해 "벡터에서 몇 등 + 키워드에서 몇 등"을 합산합니다. 양쪽에서 고루 상위인 문서가 위로 올라옵니다. k=60은 상위권 급경사를 눌러줍니다. k가 없으면 1위(1/1)와 2위(1/2)가 2배 차이지만, 60을 더하면 1/61 대 1/62로 격차가 거의 없어집니다. "1등이냐 2등이냐"의 미세한 차이는 무시하고 "상위권에 들었냐"라는 큰 신호만 남깁니다.

두 갈래가 각자 일하는 게 확인됩니다. "해외 출장 갈 때 하루에 얼마 받나"는 문서에 그 표현이 한 글자도 없는데 국외출장 일비 기준표를 찾아냈고(의미 검색), "경조휴가" 같은 정확한 용어는 키워드 검색이 상위로 끌어올립니다.

RRF가 이 파이프라인에 맞는 진짜 이유는 순위 기반이라 튜닝할 손잡이가 k 하나뿐이라는 점입니다. 대신 순위 기반이라 점수의 절대적 의미가 없어 "이 정도면 무관"을 스스로 판정하지 못합니다. 그래서 뒤에 별도의 관련도 게이트를 둡니다.


2. RRF의 동점 — 벡터 1위와 키워드 1위는 누가 이기나

자연스러운 의문이 하나 나옵니다. "벡터 1위와 키워드 1위는 같은 레벨 아닌가? 둘 사이 순위는 어떻게 매기나?"

오해의 지점은 여기입니다. RRF는 갈래별로 순위를 매기는 게 아니라 문서 하나하나에 대해 두 리스트의 등수를 전부 더합니다. 그래서 "벡터 1위 문서"의 최종 점수에는 그 문서가 키워드에서 몇 등이었는지도 이미 포함됩니다. 두 1위를 가르는 건 상대 리스트에서의 등수입니다.

예를 들어 벡터가 A, B, C 순, 키워드가 B, C, A 순이면:

문서 벡터 키워드 RRF 합
B 2위 1위 1/62 + 1/61 = 0.03252
A 1위 3위 1/61 + 1/63 = 0.03227
C 3위 2위 1/63 + 1/62 = 0.03200

벡터 1위는 A인데 최종 1위는 B입니다. B가 키워드 1위이면서 벡터에서도 2위인데, A는 벡터 1위지만 키워드에서 3위로 처졌기 때문입니다. 교차 확인이 승부를 가릅니다.

진짜 동점은 언제 생기나

딱 하나, 완벽한 거울 대칭일 때입니다. 벡터가 A, B, 키워드가 B, A이면:

RRF(A) = 1/(60+1) + 1/(60+2) = 1/61 + 1/62 = 0.032522
RRF(B) = 1/(60+2) + 1/(60+1) = 1/62 + 1/61 = 0.032522   ← 완전히 같음

RRF는 대칭 함수라 두 갈래를 맞바꿔도 값이 불변입니다. 이건 "두 검색기를 동등하게 신뢰한다"는 가정의 직접적 귀결이고, 알고리즘 결함이 아니라 순위 정보만으로는 우열을 가릴 근거가 실제로 없다는 사실을 정직하게 반영한 것입니다.

가르고 싶다면 가중 RRF로 편향을 명시하면 됩니다(w_v/(60+벡터순위) + w_k/(60+키워드순위)). 하지만 근거 없이 넣으면 그냥 편향이라, 이 구축은 균등 RRF를 쓰고 판단을 뒤로 넘겼습니다.

결국 심판은 리랭커

핵심은 RRF의 임무가 최종 순위 확정이 아니라 좋은 후보 집합(상위 12개)을 추리는 것이라는 점입니다. RRF가 두 문서를 동점으로 나란히 놔도, 둘 다 후보 안에만 들면 됩니다. 최종 서열은 크로스인코더 리랭커가 원문을 읽어 정합니다. RRF는 정밀한 랭커가 아니라 관대한 취합기로 쓰는 게 맞습니다. recall(정답을 후보에 넣기)은 RRF, precision(후보 중 정답을 위로)은 리랭커의 몫입니다.


3. 리랭커는 언제, 어떻게 순위를 뒤집나

3-1. 실행 시점 — LLM이 답하기 전, 도구 호출 안에서

리랭커는 LLM이 사용자에게 답을 쓰기 전에 돕니다. 정확히는 LLM이 search_docs 도구를 부르고 결과를 기다리는 동안, 도구 내부에서 실행됩니다.

사용자: "@dev-bot 경조휴가 며칠 쓸 수 있어?"
   │
   ▼
[LLM 에이전트]  "사규 질문 → search_docs 호출"  (여기서 멈추고 결과 대기)
   │
   ▼
[rag-mcp 컨테이너] search_docs 실행 ← 이 안에서 전 과정이 돎
   벡터 + BM25 → RRF(후보 12) → 리랭커 재정렬 → 관련도 게이트
   │  결과를 텍스트로 반환
   ▼
[LLM 에이전트]  반환된 청크를 읽고 자연어 답변 작성
   ▼
사용자에게 답변

여기서 헷갈리기 쉬운 점 하나. 리랭커는 LLM이 아닙니다. 서로 다른 두 모델입니다. 리랭커는 후보를 관련도 순으로 재정렬하는 작은 분류 모델이고, LLM은 그 결과를 읽어 답변 문장을 씁니다. 리랭커는 "LLM에게 어떤 청크를 상단에 보여줄지"를 도구 안에서 미리 고르는 단계입니다.

3-2. 6위를 1위로 — 바이인코더 vs 크로스인코더

바탕 글에서 "경조휴가 며칠"의 별표가 RRF 6위에서 리랭킹 후 1위로 올라왔습니다. 이 역전의 정체는 별표를 읽는 방식의 차이입니다.

임베딩(bge-m3)은 바이인코더입니다. 질문과 문서를 각각 따로 벡터로 압축한 뒤 거리만 잽니다. 문제는 별표가 숫자와 항목명 나열이라, 표를 1024차원 벡터 하나로 뭉개면 "경조휴가" 주제 신호가 희석됩니다. 오히려 "경조휴가는 …" 하는 조문 산문이 의미적으로 더 가깝게 나와 위로 올라오고, 정작 일수가 든 별표는 6위에 머뭅니다.

리랭커는 크로스인코더입니다. 질문과 문서를 하나로 붙여 함께 넣습니다.

입력:  [CLS] 경조휴가 며칠 [SEP] [별표1] 구분|대상|기간|본인결혼 N일|자녀결혼 N일 … [SEP]
             └───── 전체 어텐션으로 서로 참조 ─────┘
출력:  관련도 로짓 하나 (양수=관련, 음수로 갈수록 무관)

질문의 모든 토큰이 문서의 모든 토큰을 직접 봅니다. "며칠"이 별표의 "기간"·"N일"과 맞물린다는 걸 읽어냅니다. 그래서 판정이 바뀝니다. 별표1(실제 일수표)은 "며칠에 답이 여기 있다" → 높은 점수, 경조휴가를 언급만 한 조문은 "주제는 같지만 일수는 없다" → 낮은 점수.

바이인코더는 주제 유사도(관한 글이냐)를 보고, 크로스인코더는 응답 적합성(답하는 글이냐)을 봅니다. 이 차이가 6위 → 1위 역전입니다.

다만 전제가 있습니다. 리랭커는 RRF가 넘긴 후보 12개만 재정렬합니다. 전체를 다시 보지 않아요. 바탕 글 6절의 청킹 수정(별표를 독립 청크로 만들고 라벨을 색인에 포함)이 별표를 후보 안에 넣어줬기에 리랭커가 끌어올릴 수 있었습니다. 1차의 recall과 2차의 precision이 곱해져야 답이 납니다.

왜 처음부터 크로스인코더를 안 쓰나? 미리 계산이 불가능하기 때문입니다. 바이인코더는 문서 벡터를 인덱싱 때 만들어두고 질의 때 거리만 잽니다(수백만 개라도 빠름). 크로스인코더는 질문이 와야 (질문+문서) 쌍이 생겨 후보마다 실시간으로 돌려야 합니다. 그래서 후보 12개·320토큰으로 줄여 지연을 관리하고, 전체가 아니라 1차가 추린 소수에만 적용합니다.

3-3. 뒤집힌 순위는 어디에 저장되나 — 저장하지 않는다

이어지는 질문. "리랭킹으로 바뀐 순위는 어디에 저장하나? 비슷한 질문이 오면 다시 리랭킹 안 하나?"

리랭킹 결과는 어디에도 저장하지 않습니다. 질의마다 새로 계산하고 답을 만든 뒤 버립니다. 비슷한 질문이 와도 처음부터 다시 합니다.

저장(영속) 계산(휘발)
무엇 청크 원문, 임베딩 벡터, FTS5 색인 코사인 거리, BM25, RRF 순위, 리랭커 점수
어디 index.db(SQLite 디스크) RAM(도구 호출 중에만)
언제 인제스트 때 한 번 질의가 올 때 매번
질의 의존? 아니오

핵심은 순위가 청크의 속성이 아니라 (질의, 청크) 쌍의 값이라는 점입니다. "별표1은 6위"는 별표1의 성질이 아니라 "경조휴가 며칠"이라는 특정 질문에 대해서만 성립합니다. 저장해둘 "별표1의 순위"라는 게 원리적으로 존재하지 않습니다.

이게 임베딩과의 결정적 차이입니다. 임베딩은 문서만의 함수라 미리 만들어 저장하고 모든 질의가 공유합니다. 리랭커 점수는 질문이 있어야 존재하므로 저장할 대상이 없습니다. 사람 눈에 비슷한 질문도 임베딩·토큰·리랭커 입력이 모두 달라 전부 재계산됩니다.

멘탈 모델로는, 임베딩 저장이 테이블에 행을 INSERT하는 것이라면 리랭킹은 SQL의 ORDER BY입니다. ORDER BY가 원본 테이블 저장 순서를 안 바꾸듯, 리랭킹은 이번 질의의 결과 집합만 잠깐 정렬할 뿐 index.db를 건드리지 않습니다.

캐시를 얹을 수는 있습니다(완전 일치 캐시, 또는 질의를 임베딩해 재사용하는 시맨틱 캐시). 다만 비슷해 보여도 정답이 다를 수 있고 문서 재색인 시 낡는다는 대가가 있습니다. 챗봇 트래픽에서 2초대 지연이 견딜 만하고 매번 재계산이 항상 최신 색인을 반영하므로 이 시스템은 캐시를 안 썼습니다.


4. 임베딩 서빙 — Ollama의 정체와 상용화

4-1. Ollama란 무엇이고 어디서 도나

Ollama = 로컬 머신에서 모델을 쉽게 돌리는 실행 도구(런타임 + 모델 매니저)입니다. 내부적으로 llama.cpp를 감싸고, ollama pull bge-m3로 양자화된 모델을 받아 HTTP API(:11434)로 노출합니다. 설계 목적이 "노트북에서 한 줄로 모델 띄우기"라 편의성이 최우선이고, 동시성·확장·관측성은 부차적입니다.

위치가 중요합니다. Ollama는 Docker 안이 아니라 호스트(맥) 자체에서 돕니다. 근거는 셋입니다. compose 파일에 Ollama 서비스가 없고, 설정의 주소가 host.docker.internal:11434이며, 바탕 글이 "컨테이너 안이 아니라 호스트에 요청하는 구조라 VM 메모리와 무관"이라고 적었습니다.

┌───────────── 맥 (호스트) ─────────────┐
│  ollama serve :11434  ← 여기서 돎       │
│         ▲                              │
│         │ host.docker.internal:11434   │
│   ┌─────┴──── Docker VM ──────────┐    │
│   │ hermes / rag-mcp 컨테이너      │    │
│   └───────────────────────────────┘    │
└───────────────────────────────────────┘

host.docker.internal은 Docker Desktop이 컨테이너에서 호스트를 가리키도록 자동 제공하는 특수 DNS 이름입니다. 컨테이너에서 localhost는 자기 자신이라 호스트의 Ollama에 못 닿기 때문에 이 별칭을 씁니다. 맥에선 기본 내장이라 compose에 extra_hosts 설정조차 필요 없습니다.

4-2. bge-m3의 세 얼굴 — dense만 쓰는 이유

대부분 임베딩 모델은 벡터 한 종류만 냅니다. bge-m3는 특이하게 한 모델에서 셋을 뽑을 수 있습니다.

출력 형태 성격
Dense 1024차원 벡터 하나 의미 검색
Sparse 단어별 가중치 학습된 키워드 매칭
Multi-vector 토큰마다 벡터 하나(ColBERT) 토큰 단위 정밀 매칭

이 파이프라인은 dense만 씁니다. 나머지 둘은 만들지도 않습니다. 각 역할을 이미 더 값싼 게 맡고 있기 때문입니다. 키워드는 SQLite 내장 FTS5가(sparse 대신), 정밀 상호작용은 크로스인코더 리랭커가(multi-vector 대신) 합니다. Sparse를 쓰려면 별도 인덱스가 필요하고, multi-vector는 청크마다 벡터가 수십 개로 늘어 저장량이 폭발하며 특수 스코어링(MaxSim)이 필요합니다.

그래서 dense 임베더를 다른 모델로 바꿔도 잃는 게 없습니다. 원래 안 쓰던 기능이라 교체 부담이 가볍습니다.

이 설계의 철학은 "한 모델에 다 시키지 않고 각 역할을 가장 값싼 도구에 분산"입니다. 덕분에 각 부품이 교체 가능해집니다. dense 임베더를 갈아끼워도 키워드·리랭킹이 안 흔들립니다.

4-3. 무료 모델을 상용에 올릴 때 — 서빙이 문제지 모델이 아니다

"무료 Ollama bge-m3를 상용에 도입하면 문제가 없나?" 질문엔 두 층이 섞여 있습니다. 모델 자체 문제서빙 방식 문제. 진짜 리스크는 대부분 후자입니다.

서빙(Ollama 단일 호스트)의 문제:

  • 단일 장애점 — 호스트 Ollama가 죽으면 인덱싱도 질의 임베딩도 동시에 멈춥니다. 이중화·페일오버 없음.
  • 처리량 — Ollama는 로컬/개발용이라 사실상 요청을 직렬 처리합니다. 수평 확장이 없어 동시 사용자가 늘면 꼬리 지연이 튑니다.
  • 양자화 드리프트 — Ollama 기본 bge-m3는 양자화 버전일 수 있고, 업데이트로 내부 양자화가 바뀌면 같은 태그(bge-m3)라도 벡터가 미세하게 이동합니다. 상용에선 다이제스트 단위로 핀해야 합니다.

모델(bge-m3) 자체는 오히려 경쟁력이 있습니다. MIT 라이선스라 상업적 사용에 문제없고, 다국어 임베딩 중 한국어가 강한 편입니다. 그리고 자체 호스팅이라 사규 원문이 조직 밖으로 안 나갑니다 — 민감 문서에선 이게 성능이 아니라 컴플라이언스 판단으로 모델 선택을 좌우합니다.

그래서 갈림길은 두 갈래입니다.

  • 경로 A — bge-m3 유지, 서빙만 교체. 모델은 그대로 두고 Ollama를 상용 추론 서버(TEI, vLLM, Infinity 등) + GPU + 이중화로 바꿉니다. "직접 관리 vs 관리형"의 문제가 아닙니다. Ollama도 이미 자체 관리 인프라이고, 차이는 개발용 서빙 소프트웨어냐 상용급이냐입니다. 데이터가 안 나가고(프라이버시), 모델이 같아 재임베딩이 불필요합니다. 민감한 사규엔 이쪽이 유력합니다.
  • 경로 B — 관리형 상용 임베딩 API. 운영을 넘기고 SLA·오토스케일을 삽니다. 한국어를 잘하는 후보로 Voyage(멀티링구얼), Cohere embed-multilingual, OpenAI text-embedding-3 등. 대신 사규 원문이 외부로 전송되므로 no-training/DPA 계약을 반드시 확인하고, 벤더 종속을 감수합니다.

어느 쪽이든 모델을 바꾸면 전체 재임베딩이고, 인덱싱과 질의는 같은 모델·버전이어야 합니다. 그래서 문서 테이블에 임베딩 모델명을 기록해 불일치 시 인제스트를 거부하도록 막아뒀습니다.

4-4. 이 M1 맥으로 상용 서빙이 되는가

결론은 안 됩니다. 아키텍처와 리소스 두 층에서 막힙니다.

  • vLLM은 사실상 NVIDIA CUDA 전용이라 Apple Silicon에선 프로덕션으로 안 돕니다. 시작부터 제외입니다.
  • TEI는 x86 + NVIDIA GPU(또는 CPU) 대상이라, M1 CPU에서 억지로 돌려도 GPU 가속이 없어 지금 Ollama와 똑같이 CPU 바운드입니다. 상용급 이점이 0입니다.
  • 결정적으로 이 맥엔 메모리 여유가 없습니다. 물리 16GB인데 이미 스왑을 상시 10GB 넘게 쓰고 있어(Mattermost + Postgres + Hermes + rag-mcp), 임베딩 서버를 더 얹으면 아래 5-2절의 마비가 재연됩니다.
  • 역설적으로 M1에서 로컬 서빙의 최선은 이미 Ollama입니다. Ollama는 내부적으로 Metal(Apple GPU)을 쓰는데, TEI/vLLM은 CUDA용이라 M1에선 Ollama를 못 이깁니다.

"상용급 서빙"은 이 랩톱이 아니라 별도 하드웨어(리눅스 + NVIDIA GPU, 온프렘 또는 클라우드)의 몫입니다. 이 M1 맥은 개발 박스이고, 여기서는 Ollama가 최적입니다. 다만 dev와 prod의 임베딩 스택이 갈리면 양자화·버전 불일치로 벡터가 어긋날 수 있으니, 인덱스를 공유하려면 거기까지 맞춰야 합니다.


5. 배치와 운영 — 왜 이렇게 나눴나

5-1. 리랭커는 왜 컨테이너 안인가 (호스트에 안 올린 이유)

임베딩은 호스트, 리랭커는 컨테이너. 이 비대칭이 왜 생겼나. 임베딩이 호스트로 간 건 의도가 아니라 기회주의였습니다. 이미 호스트에 Ollama가 떠 있어 모델 하나만 더 얹은 것입니다. 공짜로 재사용한 셈이죠.

리랭커엔 그런 기존 서비스가 없고, Ollama는 리랭커를 못 돌립니다(리랭커는 시퀀스 분류 모델이라 Ollama가 지원하는 형태가 아님). 그러니 "호스트에서 리랭킹"은 맥에 별도의 ONNX 프로세스를 새로 띄워 관리한다는 뜻이 되는데, 그러면 잃는 게 많습니다.

  • 파이프라인 분할 — 리랭커는 1차 검색이 추린 후보를 이어받는 파이프라인 내부 부품입니다. 호스트로 빼면 검색 로직이 컨테이너↔호스트 경계를 넘나들고 실패점이 하나 더 늡니다.
  • 이식성 파괴 — 이 서버는 개발 맥에서 서버로 옮겨 다닙니다. 리랭커가 호스트에 있으면 머신마다 ONNX 데몬을 따로 세팅해야 합니다. 이미지에 구워 넣으면 모델이 컨테이너와 함께 이동합니다.

비대칭의 정체는 이것입니다. 임베딩은 편승할 호스트 서비스가 있었고, 리랭커는 없었으며 파이프라인 내부라 컨테이너에 넣는 게 깔끔했습니다. 대가가 다음 절의 메모리 사고였고, 그걸 파이프라인 분할보다 나은 거래로 판단했습니다.

5-2. VM 8GB가 호스트를 마비시킨 정확한 이유

바탕 글의 가장 큰 사고입니다. 한 문장으로: 물리 RAM이 아니라 이미 바닥나 있던 스왑을 8GB 할당이 마저 고갈시켜 스래싱에 빠졌기 때문입니다.

단계별로 보면,

  1. 맥의 Docker는 리눅스 VM 안에서 돈다. 컨테이너가 보는 "Total Memory"는 VM에 배정된 양이고, 그 메모리는 맥의 물리 RAM에서 떼어옵니다.
  2. 호스트가 이미 스왑을 12GB 상시 쓰고 있었다. 물리 16GB인데 여러 컨테이너가 초과 사용해 macOS가 이미 페이지를 대량으로 스왑에 밀어낸 상태였습니다. "여유 46%"로 보인 건 스왑으로 backing된 inactive 메모리였지 진짜 가용 RAM이 아니었습니다.
  3. VM을 8GB로 올리자 macOS가 그만큼을 더 확보하려 함. 물리 RAM을 비우려 페이지를 더 스왑으로 내보내는데, 남은 스왑이 얼마 없었습니다.
  4. 스왑이 고갈되자 모든 메모리 접근이 디스크 폴트가 되어 스래싱. 시스템이 페이징에만 매달려 Docker 데몬도, 단순 조회도, 헬스체크도 전부 타임아웃. 이건 컨테이너 OOM(exit 137, 2GB일 때)이 아니라 호스트 전체의 스왑 고갈 마비입니다.
  5. 4GB로 되돌리니 정상. 컨테이너 실사용 합이 약 2.4GB라 4GB VM은 호스트를 스왑 한계 너머로 밀지 않습니다.

반직관의 핵심: 진짜 제약은 물리 RAM이 아니라 남은 스왑이었습니다. 그리고 작은 VM은 컨테이너가 OOM으로 자기만 죽지만, 큰 VM은 호스트 자원을 빨아들여 시스템 전체를 죽입니다. 격리된 실패가 전역 실패로 번진 것. 리소스는 총량이 아니라 실제 여유를 보고 정하고, 올린 뒤 반드시 부하를 걸어 확인해야 합니다.

5-3. 사용 중인 리랭커 — jina-reranker-v2-base-multilingual

코드로 확인한 실물입니다. 모델은 jina-reranker-v2-base-multilingual, int8 양자화, ONNX 포맷 (약 278M). 바탕 글에서 568M 모델을 측정 후 지연 때문에 낮춘 그 다국어 모델이 이것입니다.

  • 실행: ONNX Runtime, CPU 실행(GPU 안 씀), 4스레드, 최대 320토큰, 배치 8. (query, passage) 쌍을 넣어 로짓 하나를 출력합니다(양수=관련, 음수=무관).
  • 위치: rag-mcp 컨테이너 내부 /opt/reranker에 모델·토크나이저가 이미지로 구워짐(런타임 다운로드 없음). 컨테이너 기동 시 미리 로드해 첫 질문이 로드 지연을 안 떠안게 합니다. 상주 약 1.1GB.

임베딩(호스트 Ollama)과 리랭커(컨테이너 내부 ONNX)의 배치가 갈린 이유가 5-1절 그대로입니다.

5-4. FastMCP와 도구 3개

FastMCP는 파이썬 함수를 MCP 서버로 만들어주는 프레임워크입니다. @mcp.tool() 데코레이터만 붙이면 함수 시그니처와 독스트링을 읽어 도구 스키마를 자동 생성해 클라이언트(Hermes)에 알리고, mcp.run(transport="streamable-http") 한 줄로 HTTP 서버를 띄웁니다. Flask가 함수를 HTTP 엔드포인트로 만들어주듯, FastMCP는 함수를 MCP 도구로 만들어줍니다.

검색용인데 도구를 셋이나 만든 이유는 각각 다른 접근 패턴을 담당하기 때문입니다. 규정집을 쓰는 사람의 세 행동에 대응합니다.

도구 사람으로 치면 용도
search_docs(query, top_k) 색인에서 키워드로 찾기 발견(fuzzy). 하이브리드 검색
read_article(doc_id, article) 해당 조문 펼쳐 통독 정밀(exact). 잘리지 않은 원문
list_docs() 목차/장서 확인 탐색(metadata). 청크 수·파서·상태

search_docs는 랭킹된 청크 조각을 주지만 조문이 잘려 있을 수 있어, "제5조 전문을 정확히 인용"하려면 read_article이 필요합니다. LLM이 "우리 회사에 그런 규정이 있긴 한가"를 판단하려면 list_docs로 커버리지를 알아야 합니다. 진짜 힘은 도구 체이닝입니다. search_docs가 "제8조"를 언급한 결과를 주면 → read_article로 제8조 전문을 확보해 정확히 인용하는 식으로, LLM이 사람처럼 다단계로 추론합니다.

5-5. MCP 검색이 파일검색보다 나은 점

MCP가 끊겼을 때 봇이 파일검색으로 답한 적이 있는데(바탕 글 11절), 그때 나온 건 "그럴듯한 오답"이었습니다. 설령 파일검색이 우연히 맞는 답을 내더라도 신뢰할 수 있다는 뜻은 아닙니다. MCP 검색이 구조적으로 나은 이유가 있습니다.

  • 의미 검색 — grep은 단어가 정확히 일치해야 합니다. "해외 출장 하루 얼마"로는 "국외출장 일비" 문서를 못 찾습니다. 벡터 검색은 찾습니다.
  • 포맷 — 코퍼스는 레거시 doc·hwp(OLE 바이너리)와 스캔 PDF(텍스트 레이어 없음)를 포함합니다. grep은 이 대부분을 읽지도 못합니다. RAG는 파서 4종 + OCR로 미리 텍스트로 만들어 인덱싱했습니다.
  • 랭킹과 "근거 없음" 판정 — grep은 매칭을 순위 없이 쏟아내고, "이건 규정에 없다"를 판정하지 못합니다. RAG는 관련도 게이트로 근거 없음을 말할 수 있습니다.
  • 인용과 실패 방식 — RAG는 【문서명 › 조항】으로 정확히 인용하고, 실패하면 명시적으로 거절합니다. 파일검색의 실패는 조용한 오답 — 가장 위험한 실패입니다.

MCP의 본질 가치는 "검색 성능"이 아니라 신뢰 가능한 실패(fail loud)와 인용 가능한 근거입니다. 규정 질문에서 "조용히 틀림"은 "모른다고 함"보다 나쁩니다. 사용자가 틀린 걸 믿고 행동하니까요. 그래서 페르소나 지침으로 파일검색 폴백을 아예 금지했습니다.


6. 곁가지 — 문서와 코드의 사소한 불일치 하나

되묻는 과정에서 리랭커 소스를 열었더니, 모듈 독스트링이 아직 옛 실험 모델명(bge-reranker-v2-m3, 568M)으로 적혀 있었습니다. 실제 이미지에 든 자산과 최종 결정(jina 278M)과 어긋난 표기입니다. 코드는 지정된 디렉터리의 모델 파일을 로드할 뿐 모델명을 하드코딩하지 않아 동작엔 문제없었지만, 문서 불일치라 독스트링을 실제 모델명으로 고쳤습니다. Dockerfile의 bge-reranker-v2-m3 언급은 "더 강한 모델로 승격하려면 이렇게"라는 의도된 안내라 그대로 뒀습니다.

작은 일이지만, 실험하며 갈아탄 흔적이 주석·독스트링에 남는 건 흔한 일입니다. 코드가 정답을 말해주니 되물어 확인하는 값어치가 여기 있습니다.


정리 — 되물으면서 분명해진 것들

한 줄씩 되물어 얻은 것을 압축하면 이렇습니다.

질문 요지
trigram을 쓴 이유 한국어 조사 때문. 기본 토크나이저는 매칭 자체가 실패
RRF 동점은? 대칭 함수라 거울 대칭이면 진짜 동점. 심판은 리랭커
리랭커 시점 LLM보다 먼저, 도구 호출 안에서 도는 별개 모델
6→1 역전 주제 유사도(바이인코더) → 응답 적합성(크로스인코더)
리랭킹 순위 저장? 안 함. 순위는 청크가 아니라 (질의,청크) 쌍의 값
무료 임베딩 상용화 문제는 모델이 아니라 서빙(단일 호스트)
M1으로 상용 서빙? 불가. vLLM은 CUDA 전용, 메모리도 포화
8GB 마비 물리 RAM이 아니라 스왑 고갈로 인한 호스트 스래싱

관통하는 주제는 앞선 글과 같습니다. 부품을 나누고, 각 부품에 딱 맞는 도구를 붙이고, 애매하면 더 많은 정보를 가진 쪽에 판단을 넘긴다. 되물어 코드까지 확인하니 그 선택들이 임의가 아니라 제약에서 나온 것임이 보였습니다.


관련 글