코드베이스 검색 도구에 한국어로 물어봤더니 열두 문항이 전부 빗나갔다. “로그인 처리가 어느 파일에 있지” 같은 실사용 스타일 질문 12개를 던졌는데 정답 파일이 top-1에 들어간 게 0건이었다. 결론부터 말하면 임베딩 모델이 코드 전용 영어 모델이었고, Qwen3-Embedding-0.6B로 교체하면서 파일 적중 top-1이 1에서 8로 올라갔다. 그런데 원인을 추적하는 동안 “도구를 버려야 하나”까지 갔었고 반증 실험 하나로 그 판단을 되돌린 게 이번 사건의 핵심이었다.
증상: 검색 결과가 이상하게 정체돼 있다
내 에이전트 환경에는 키워드 검색(FTS5의 BM25)과 임베딩 의미 검색을 RRF로 섞는 하이브리드 코드 검색이 붙어 있다. 품질 검증을 처음 돌려봤는데 결과가 이상했다. 한국어 질의 12문항에 정답 코드 파일이 top-1에 한 번도 안 나왔고 한 문항은 아예 관련 없는 결과만 나왔다.
처음엔 랭킹 문제라고 생각했다. 코드 파일이 아닌 문서가 위로 올라와서 그런가 싶어서 “첫 번째 코드 파일을 우선”하는 리랭킹 필터를 붙여봤다. 필터를 적용해도 top-1은 여전히 0/12였다. 랭킹이 아니라 더 아래쪽, 후보를 만드는 단계부터 잘못되어 있다는 신호였다.
결과를 들여다보니 더 기묘한 패턴이 있었다.
질문이 제각각인데 같은 청크 하나가 자꾸 top으로 올라왔다.
나중에 벤치마크에서 밝혀지지만 16개 쿼리 중 10개의 top-1이 ctx-guard/index.ts의 같은 위치였다.
검색이 “찾는다”가 아니라 “아무거나 내놓는다” 상태로 퇴화해 있었던 것이다.
원인 분리: 도구가 문제인가, 질의가 문제인가
원인 후보는 세 개로 좁혀졌다.
첫째, 한국어 조사가 키워드 경로를 망가뜨렸다. “어떻게”, “파일이” 같은 상용어와 조사가 한국어 주석이 달린 파일 전체에 균일하게 매칭되면서 BM25의 식별력이 사라졌다. 모든 파일이 비슷하게 걸리면 랭킹은 무의미해진다.
둘째, 임베딩 모델이 한국어 의역을 못 잡았다.
“결제 재시도”와 retryPayment를 연결하지 못하면 의미 검색의 존재 이유가 없다.
셋째, 두 경로를 섞는 퓨전에서 점수와 순위가 어긋나는 별개 버그 후보도 보였다. 정답 문서가 최강 점수를 받고도 4위에 있던 사례가 있었다.
여기서 멈추면 결론은 “도구를 버린다”가 된다. 그런데 그 결정을 내리기 전에 분리 실험을 하나 돌렸다. 같은 질문 12개의 의도를 유지한 채 표현만 영어와 식별자로 바꾼 것이다.
결과는 4개 질문 전부 top-1 또는 top-2로 정답이 돌아왔다. 인프라와 인덱스는 멀쩡했고 부서진 건 한국어 질의 경로 한쪽이었다. “도구 결함”과 “질의 경로 결함”을 나눠서 실험하지 않았다면 폐기급 결정을 그냥 실행했을 것이다.
진짜 범인: 모델 정체 확인부터 다시
다음 문제는 “지금 돌아가는 임베딩 모델이 뭐냐”였는데 여기서 이전 세션 보고가 오보였음이 드러났다.
직전 조사에서는 nomic v1.5를 쓰는 중이라고 정리되어 있었다.
config 기본값이 아니라 실행 중인 프로세스의 실제 환경변수를 ps eww로 확인하고 디스크의 모델 캐시와 config까지 대조하니 세 증거가 한 방향을 가리켰다.
실행 중 모델은 CodeRankEmbed Q8_0이었다.
정체를 알고 보니 답이 명확했다. CodeRankEmbed는 137M 파라미터 bi-encoder로, 2,100만 코드 페어로 학습된 코드 검색 전용 모델이다. 학습 언어는 Python, Java, JavaScript, PHP, Go, Ruby. 한국어는 애초에 학습 목표가 아니었다. 영어 코드 질의에서는 잘 작동하고 한국어에서 무너지는 게 이상한 일이 아니었다.
이번에 얻은 교훈은 “모델 확인은 소스의 기본값이 아니라 실행 중 프로세스의 실제 값으로”다. 데몬 기본값과 워커 기본값이 서로 다른 문자열을 가리키고 있으면 문서상 값은 아무것도 보증하지 않는다.
4모델 벤치마크: 300청크 × 16질문
교체 후보를 리서치해서 4모델을 격리 환경에서 실측했다. 코드 청크 300개에 질문 16개를 던지는 동일 조건이었다.
| 모델 | dim | ms/청크 | 파일 적중 @1/@3/@5 |
|---|---|---|---|
| CodeRankEmbed Q8 (현행) | 768 | 38.9 | 1/4/5 |
| qwen3-embedding-0.6B Q8 | 1024 | 141.9 | 8/11/14 |
| granite-embedding 97m | 384 | 13.9 | 5/11/11 |
| granite-embedding 311m* | 768 | 39.8 | 3/6/8 |
(*) granite 311m은 커뮤니티 변환본 측정이라 신뢰도 캐비앗이 붙는다. 공식 변환본이 설정 키 누락으로 로드에 실패해서 대안 변환본으로 측정했는데 형제 모델인 97m이 311m을 이기는 결과는 변환 자체의 결함을 시사한다. 로드 실패는 모델 미지원과 다른 문제라서 다른 변환본을 다시 시도하는 게 정석이라는 것도 이번에 배웠다.
qwen3-0.6B가 압승이었다. 파일 적중 top-1이 현행 1에서 8로, 확장 단위 적중 top-1은 11까지 올라간다. 청크당 141.9ms로 현행의 3.6배 느리지만, 임베딩은 인덱스 시점에 미리 계산하는 구조라 쿼리 응답에는 영향이 없다. 검색 품질과 인덱싱 비용 중 전자를 택했다.
전 모델을 통틀어 못 푸는 질문도 있었다. 어휘가 하나도 안 겹치는 순수 의역 질문 두 개는 모델을 바꿔도 놓쳤다. 이건 잔여 난제로 남겼다.
스왑 실행: 조용한 함정 두 개
모델 교체 자체는 설정 한 줄이지만 실제로는 함정이 두 개 있었다.
첫째는 GGUF 캐시 파일명이다.
llama.cpp의 파서가 저장소 id에서 -GGUF 접미사를 떼내기 때문에, Qwen3-Embedding-0.6B-GGUF를 내려받아도 실제 캐시 파일명은 접미사 없는 형태로 생성된다.
프리시드 캐시를 잘못 넣으면 다시 600MB를 내려받는다.
캐시 히트가 1ms인 것까지 확인하고 넘어갔다.
둘째는 차원 변경이다. CodeRankEmbed는 768차원, Qwen3-0.6B는 1024차원이라 기존 벡터와 호환이 안 된다. 임베딩 테이블을 통째로 지우고 전체 재스캔을 돌렸다. 결과는 파일 507개, 청크 2,001개 중 1,998개 임베딩, 약 352초. 저장된 벡터 하나가 정확히 4,096바이트인 것까지 확인해서 1024차원 적용을 검증했다. 그리고 예전 모델 파일은 삭제하지 않고 별도 디렉토리로 옮겼다. 첫 로드 경로에 간섭하는 사고를 막기 위해서다.
교체 후 실전 하이브리드 검색에서 확장 16문제 hit@1/3/5가 12/15/15로 나왔다. 첫 쿼리는 모델 로드 포함 1,769ms, 이후엔 60~110ms. 인덱스 시점에 비용을 몰아넣는 구조가 그대로 효과를 내는 구간이다.
마무리로 남긴 것: 질의 언어 게이트
마지막 결정이 가장 흥미로웠다. 원래 “한국어 질의는 영어로 바꿔서 조회하라”는 게이트가 있었는데 근거가 “모델이 한국어를 못해서”였다. 다국어 모델로 바꿨으니 게이트를 없애는 게 자연스러워 보였다.
그런데 A/B를 돌려보니 같은 개념 질의가 한국어로는 9위, 영어로는 3위였다. 다국어 임베딩이 붙어도 하이브리드의 절반을 차지하는 FTS5 키워드 경로가 영어에 구조적으로 더 강했다. 결론은 게이트 유지, 근거 교체였다. 정책은 유지하되 “왜”를 정확한 근거로 다시 쓴 것이다. 근거가 틀린 정책은 다음 결정에서 어긋나기 마련이다.
게이트의 거부 메시지는 영어 재작성을 유도하도록 설계돼 있어서 실측에서도 9위 결과가 3위로 올라가는 효과를 냈다. 영어 글자 비율 임계값은 0.3으로 두고 짧은 입력의 오판을 막는 최소 글자 수 조건을 앞세웠다.
정리하면 이번 사건에서 남은 건 세 가지다. 한국어 질의 붕괴의 원인을 모델 교체로 해결했고 폐기 판정을 되돌린 matched-pair 분리 실험 방법론을 확보했고 게이트 유지 결정의 근거를 사실로 교체했다. 못 끝낸 것도 명확하다. 어휘 겹침 0인 순수 의역 질문은 여전히 못 잡는다. 당분간은 게이트의 영어 재작성 유도가 최선의 우회로다.