AI로 장소 검색 서비스 만드는 중입니다 (기술 1) — 로컬 LLM의 벽

t1-rerank-ndcg

연재 본편이 "삽질기"라면, 이제부터 이어지는 기술편 10편은 그 뒤에서 돌린 A/B와 평가의 실제 숫자입니다. 본편 4편에서 "메모리가 터졌다"고만 하고 넘어갔는데, 이번 글은 그 장비의 정확한 스펙과, 로컬 모델이 정확히 어디서·왜 무너졌는지를 숫자로 남깁니다. 그리고 이 글에는 예상 못 한 결말이 하나 있습니다. 가장 크게 기대했던 기능이, 안 쓰는 것보다 나빴습니다. 모든 수치는 유추카 자체 실측(2026-06~07)입니다. 지연·메모리는 아래 장비 기준의 상대값이라 절대 벤치마크가 아니라 "이 조건에서 무엇이 되고 안 됐나" 로 읽어 주세요.

"내 GPU"의 정체

로컬 LLM을 돌린 장비는 이겁니다.

  • MacBook Pro, Apple M3 Max, 통합 메모리 36GB, macOS
  • 런타임: Ollama 0.30.8 (백그라운드 상주, 기본 127.0.0.1:11434)
  • 구성: LLM을 돌리는 맥북과 MongoDB가 도는 맥북이 서로 다른 기계입니다. 다른 장비에서 이 M3 Max의 Ollama를 쓸 때는 랜 IP로 직접 붙이거나 터널로 노출했습니다.

"36GB면 넉넉하지 않나?" 싶은데, 여기서 중요한 단어가 통합 메모리입니다.

통합 메모리(unified memory): 게이밍 PC의 NVIDIA 그래픽카드는 GPU 전용 메모리(VRAM)를 따로 갖습니다. VRAM이 24GB면 그 24GB는 오롯이 모델 것이고, 시스템 램은 별개죠. 반면 Apple Silicon은 CPU·GPU·시스템이 같은 메모리를 나눠 씁니다. 데이터 복사가 없어서 빠르다는 장점이 있지만, "내 몫"이 고정돼 있지 않다는 뜻이기도 합니다.

즉 36GB는 모델 몫이 아니라 전부의 몫입니다. 여기에 모델 가중치, 추론 중 만들어지는 캐시, 검색 인덱스, 웹서버, 브라우저까지 얹히면 실제로 모델이 쓸 수 있는 건 훨씬 적어집니다. 본편에서 겪은 메모리 벽의 진짜 원인이 이 공유 구조였습니다.

모델 하나가 방을 다 차지한다

모델별로 얼마나 먹고 얼마나 빠른지 재봤습니다.

로컬 모델 용도 메모리 속도(실측)
qwen3:30b-instruct (MoE, 활성 3B) 리랭크·추출 ~18GB 65 tok/s·추출 1216초/건
qwen3:14b (dense) 추출·검색 이유 ~9.3GB thinking 켜면 58초 · 이유 생성 2.26초
qwen3:8b (dense) 리랭크 후보 ~6GB 리랭크 10.6초
qwen3-embedding:4b 임베딩(2560차원) ~2.5GB 상주

30B 모델의 "MoE, 활성 3B"라는 표기를 짚어둘 만합니다.

MoE(Mixture of Experts): 모델 안에 여러 전문가 모듈을 두고, 입력마다 그중 일부만 켜서 계산하는 구조. 30B 모델인데 한 번에 3B만 켜니 계산이 빠릅니다(그래서 65 tok/s가 나옵니다). 그런데 어느 전문가가 켜질지 미리 알 수 없으니 30B 가중치를 전부 메모리에 올려둬야 합니다.

"빠른데 무겁다." 이게 로컬 대형 모델의 딜레마였습니다. 속도는 3B급인데 메모리는 30B급을 요구하니, 메모리가 병목인 환경에서는 최악의 조합입니다.

그리고 서비스는 모델 하나로 안 돌아간다

문제는 이 서비스가 여러 모델을 동시에 필요로 한다는 점이었습니다.

  • 문서와 검색어를 좌표로 바꾸는 임베딩 모델은 검색할 때마다 필요하니 항상 상주해야 합니다.
  • 영상에서 장소를 뽑는 추출엔 큰 모델이 필요합니다.
  • 검색 결과에 "왜 이 장소인지" 한 줄을 붙이는 데 또 다른 모델이 붙습니다.

계산해 보면:

30b(18GB) + 14b(9.3GB) + 임베딩(2.5GB)30GB

36GB에서 30GB를 모델이 먹으면 검색 인덱스와 웹서버가 들어갈 자리가 없습니다. 실제로 셋을 동시에 올리자 OOM(메모리 부족) 이 났습니다.

OOM이 나면 그냥 느려지는 게 아닙니다. macOS는 메모리가 부족하면 디스크로 스왑을 시작하는데, 모델 가중치가 스왑에 들어가면 토큰 하나 생성할 때마다 디스크를 읽습니다. 체감상 "느려짐"이 아니라 "멈춤"에 가깝습니다.

그래서 원칙을 세웠습니다. 채팅 모델은 하나만 상주시킨다.

14b 단일(9.3GB) + 임베딩 4b(2.5GB) = 11.8GB

이 정도면 스왑 없이 돕니다. 대신 큰 모델(30b)을 포기해야 했고, 그럼 품질이 떨어질 거라고 생각했습니다.

반전 ① — 작은 모델이 더 나았다

30b를 14b로 내리기 전에, 정말 품질이 떨어지는지 재봤습니다. 결과가 예상 밖이었습니다.

속성 추출(리뷰에서 특징을 뽑아 정규 목록에 매핑, 기술 6편) 비교:

qwen3:14b qwen3:30b
매핑률 (한식 표본) 78% 40%

작은 모델이 두 배 가까이 나았습니다.

영상 장소 추출도 봤습니다. 30b가 후보를 더 많이 뽑긴 했는데, 더 뽑은 것들을 열어보니 이런 것들이었습니다.

"음악분수", "라면조리실"

장소가 아니라 시설입니다. 검증 단계에서 어차피 전부 탈락할 것들이었죠. 반대로 14b가 놓친 진짜 장소는 영상당 약 0.2곳으로 드물었습니다.

즉 30b는 더 많이 뽑았지만 그 대부분이 노이즈였습니다. "더 큰 모델이 더 많이 찾아낸다"는 사실이었는데, "더 많이 찾아낸다"와 "더 잘한다"가 달랐습니다.

메모리 제약 때문에 어쩔 수 없이 내린 결정이 품질에도 나은 선택이었던 셈입니다. 재보지 않았으면 "30b를 못 써서 품질을 희생했다"고 기록했을 겁니다.

반전 ② — '생각하기'를 끄려다 통신 규약을 갈아엎다

14b로 통일하려면 먼저 풀어야 할 게 있었습니다. thinking 모드입니다.

thinking(reasoning) 모드: 최근 모델들은 답하기 전에 혼자 추론 과정을 길게 써 내려간 뒤 최종 답을 냅니다. 수학이나 복잡한 추론엔 큰 도움이 되지만, 출력 토큰이 몇 배로 늘어납니다. 그리고 기술 9편에서 보듯 LLM 지연은 출력이 지배합니다.

qwen3 계열은 이 모드가 기본이었습니다. qwen3:14b에 간단한 판정 하나를 시키면 58초가 걸렸습니다. "이 집이 한옥인가 아닌가" 같은 단순 분류에 58초는 말이 안 됩니다. 8~10배 느림만 유발하고 정확도는 그대로였고요.

당연히 껐습니다. 그런데 여기서 이상한 일이 생겼습니다.

당시 우리는 Ollama를 OpenAI/Anthropic 호환 경로로 부르고 있었습니다. 표준 인터페이스를 쓰면 나중에 클라우드로 갈아타기 쉬울 테니까요. 그 경로로 thinking을 끄는 신호를 보냈더니 — 모델이 빈 답을 돌려줬습니다.

프롬프트 문제인 줄 알고 한참 헤맸는데, 원인은 호환 계층이었습니다. 호환 API는 여러 모델의 공통분모만 다루기 때문에, 그 런타임에만 있는 옵션은 전달할 방법이 없거나 이상하게 처리됩니다. thinking 스위치가 딱 그런 옵션이었습니다.

결국 통로 자체를 바꿨습니다. Ollama 네이티브 API(/api/chat + think: false) 로요. 그제야 "생각하지 말고 바로 답해"가 제대로 전달됐습니다.

교훈: 호환 계층은 이식성을 사는 대신 표현력을 팝니다. 벤더 고유 기능이 필요한 순간, 호환 API는 그걸 전달할 방법이 없습니다. 그리고 그런 순간은 대개 성능이 걸린 지점에서 옵니다.

이 전환이 나중에 뜻밖의 자산이 됩니다. "프로토콜마다 다르게 말하되 프롬프트와 파싱은 공유하는" 클라이언트 구조가 여기서 만들어졌고, 그 덕에 클라우드로 갈아탈 때 엔드포인트만 바꾸면 됐습니다(기술 2편).

전환하면서 곁가지도 몇 개 고쳤습니다. temperature: 0(구조화 출력 안정), 응답이 길이 제한에 잘렸으면 성공으로 처리하지 않기, 그리고 호출자별 타임아웃이 실제로 반영되게 하기 — 원래는 어떤 값을 넣든 무시되고 120초로 고정되고 있었습니다.

결정적 반전 ③ — 로컬 리랭커가 cosine을 못 이겼다

메모리와 속도는 그렇다 쳐도, 가장 뼈아팠던 건 품질이었습니다.

가장 크게 기대한 기능은 리랭크였습니다. "임베딩 유사도로 뽑은 순위를, 문장을 읽을 줄 아는 LLM이 다시 매기면 당연히 좋아지겠지."

측정 설계는 이랬습니다. 후보를 뽑는 단계까지는 완전히 동일하게 두고, 리랭커만 교체했습니다. 그래야 차이가 순전히 리랭커 때문이라고 말할 수 있으니까요.

nDCG@10: "상위 10개에 정답을 얼마나 위쪽에 잘 배치했나"를 0~1로 재는 표준 지표. 단순 정답률과 달리 순서를 봅니다 — 같은 정답이라도 1등에 있으면 높은 점수, 9등에 있으면 낮은 점수를 줍니다. 1에 가까울수록 좋습니다.

같은 정답셋(119쿼리) 매크로 평균 결과입니다.

리랭커 nDCG@10 vs cosine 지연 / 메모리 온라인 가능?
qwen3:30b-instruct 0.594 +0.042 ~20초 / 18GB ❌ 너무 무거움
haiku (클라우드) 0.569 +0.017 3.2초
none (cosine 순위 그대로) 0.552 0초
qwen3:14b 0.541 −0.011 19.6초 ❌ (파싱도 44/119 실패)
qwen3:8b / gemma3:12b 0.540 −0.012 10.6초
exaone3.5:7.8b 0.462 −0.090

표를 읽는 법: 굵게 표시한 none이 기준선입니다. 아무것도 안 하고 코사인 유사도 순서를 그대로 쓴 것이죠. 그 아래 줄들은 전부 LLM으로 재정렬한 결과가 아무것도 안 한 것보다 나빴다는 뜻입니다.

온라인에서 쓸 수 있는 로컬 모델 중 코사인을 이긴 게 하나도 없었습니다. 유일한 승자인 30B는 20초·18GB라 사용자 검색에 못 씁니다.

덤으로 14b는 재정렬을 시키면 119개 중 44개에서 출력 파싱이 깨졌습니다. thinking을 껐는데도 형식을 이탈하는 경우가 남아 있었습니다. 순위표를 통째로 재배열해서 내놓는 일 자체가 이 크기 모델에겐 버거웠던 것 같습니다.

혹시 우리 자가 편향된 건 아닐까

이 결과가 워낙 예상 밖이라 자를 의심했습니다. 판정자를 바꾼 정답셋으로 다시 쟀지만 순서는 그대로였습니다(none 0.500 · 30b 0.535 · haiku 0.509).

그런데 더 정직하게 말하면, 이 측정에는 코사인에게 유리한 구조적 편향이 있었습니다.

정답셋의 정답들이 코사인 유사도 상위에서 뽑혀 라벨된 것이었거든요. 즉 정답 자체가 "코사인이 잘 찾는 것들"로 기울어 있었습니다. 리랭크의 이득은 오히려 과소평가됐을 가능성이 큽니다.

그런데도 30b는 그 편향을 뚫고 이겼습니다. 그래서 결론을 이렇게 정리했습니다.

  • 리랭크에 실제 신호가 있는 건 30B급부터다. 그 아래는 편향을 감안해도 이득이 안 보인다.
  • "지연이 느리다"는 이 장비 사정이라 더 좋은 GPU면 달라질 수 있지만, "품질이 코사인만 못하다"는 하드웨어와 무관하다.

측정의 한계를 같이 적어두면, 나중에 조건이 바뀌었을 때 재도전할지를 판단할 수 있습니다. "안 된다"만 적어두면 다시는 안 하게 되고, "왜 안 됐고 무엇이 바뀌면 될지"를 적어두면 재평가가 가능합니다. 실제로 나중에 클라우드 모델로는 리랭크를 다시 도입했습니다 — 다만 역할을 바꿔서요.

그럼 로컬 모델은 어디에 남겼나

재정렬엔 못 써도, 검색 이유 한 줄 생성은 성격이 다릅니다. 순위표를 통째로 다루는 게 아니라 장소 하나를 읽고 한 문장 쓰는 일이라, 입력도 작고 출력도 짧습니다. 여기는 로컬도 될 것 같았습니다.

두 모델을 A/B했습니다. 프롬프트에는 "40자 이내", "최고!·필수! 같은 홍보성 표현 금지"라는 제약을 넣었습니다.

모델 지연 평균 길이 홍보어 위반 판정
qwen3:14b 2.26초 43자 0 / 15 ✅ 규율 준수
exaone3.5:7.8b 1.09초 73자 (목표 초과) 8 / 15 ❌ 빠르나 지시 미준수

exaone이 2배 빨랐습니다. 그리고 2배 빠른 걸 못 썼습니다.

길이 제한을 어기고(43자 목표에 평균 73자), 금지한 홍보어를 15개 중 8개에서 썼기 때문입니다. 프롬프트를 강화해도 안 지켜졌습니다. 7.8B 정도 크기에서는 "지시를 따르는 능력" 자체가 부족했습니다.

작은 모델의 진짜 약점은 지식이 아니라 순종입니다. "40자로 써라", "이 단어는 쓰지 마라", "JSON만 출력해라" 같은 제약을 못 지킵니다. 그리고 파이프라인에서는 형식을 안 지키는 출력이 틀린 출력보다 더 곤란합니다 — 파싱이 깨지면 그 건은 통째로 날아가니까요.

그런데 2.26초도 못 썼다

qwen3:14b는 규율을 지켰지만 다른 문제가 있었습니다. 모델이 하나뿐이라 요청이 직렬로 처리됩니다. 화면에 카드 5개면 5 × 2.26초 = 11초입니다.

실제로 2건만 붙여도 전역 10초 타임아웃에 걸려 취소됐습니다. 그래서 감싸는 구조를 만들었습니다.

  • 검색 직후 목록이 아직 접혀 있는 동안 상위 5건을 백그라운드로 미리 생성
  • 한 번에 3건씩 나눠 호출 (한 호출이 타임아웃 안에 끝나도록)
  • 이 요청만 별도 타임아웃 30초
  • 그리고 이유가 안 와도 AI 리뷰 요약이 먼저 떠 있어서 화면이 비지 않음

"로컬로 충분히 좋게"는 됐는데 "충분히 빠르게"가 안 됐고, 그 간극을 UX 설계로 메운 겁니다.

임베딩만은 로컬이 나쁘지 않았다

의외로 임베딩만은 로컬이 클라우드에 안 밀렸습니다. 로컬 qwen3-embedding:4b(2560차원)와 클라우드 text-embedding-v4(2048차원)의 정밀도 비교입니다.

검색 유형 v4 (클라우드) 4b (로컬)
의도 0.84 0.88
지역 0.70 0.81
페르소나 0.81 0.85
랜드마크 0.65 0.59
전체 0.75 0.78

오히려 로컬이 약간 높았습니다. 생각해 보면 당연한데, 임베딩은 생성이 아니라 표현입니다. 문장을 좌표로 바꾸는 일에는 지시를 따를 필요도, 형식을 지킬 필요도 없습니다. 앞에서 작은 모델이 무너졌던 축들이 여기선 아예 요구되지 않습니다.

다만 클라우드 쪽에서 함정을 하나 발견했습니다. 호환 엔드포인트로 임베딩을 부르면 "이건 검색어인가 문서인가"를 알려주는 옵션이 안 실립니다. 이 구분이 빠지자 의도 검색 정밀도가 0.84에서 0.32로 붕괴했습니다. 앞서 thinking 스위치와 정확히 같은 종류의 사고입니다 — 호환 계층이 벤더 고유 옵션을 삼킨 거죠.

그래서 임베딩은 네이티브 API + 2048차원 + 검색어와 문서에 같은 모델을 반드시 지킵니다. (최종적으로는 운영 편의를 위해 클라우드로 통일했지만, "로컬이 못해서"는 아니었습니다.)

이 글의 결론

  • 로컬 LLM의 벽은 "느리다"가 전부가 아닙니다. 통합 메모리를 나눠 쓰는 구조에서 여러 모델을 동시에 못 올리는 것(필요 30GB vs 가용 36GB)이 더 근본적인 제약이었습니다.
  • 더 큰 모델이 더 나은 건 아니었습니다. 30b는 14b보다 후보를 더 많이 뽑았지만 대부분 노이즈였고, 속성 매핑은 오히려 14b가 두 배 가까이 정확했습니다.
  • 호환 계층은 이식성을 사고 표현력을 팝니다. thinking 스위치도, 임베딩의 질의/문서 구분도 호환 API에서 사라졌습니다. 성능이 걸린 옵션일수록 네이티브 경로가 필요합니다.
  • 가장 기대했던 리랭크는, 온라인 가능한 로컬 모델 중 코사인을 이긴 게 하나도 없었습니다. 측정이 없었으면 무거운 리랭커를 넣고 "품질을 개선했다"고 믿었을 겁니다.
  • 다만 그 측정의 한계도 같이 기록했습니다(정답셋이 코사인에 유리했음). 그래야 조건이 바뀌었을 때 재도전할지 판단할 수 있습니다.
  • 작은 모델의 약점은 지식이 아니라 순종이었습니다. 2배 빠른 모델을 형식 위반 때문에 못 썼습니다.
  • 그래서 리랭커의 역할을 "재정렬"이 아니라 "부적합 컷 + 이유 생성" 으로 재정의하고, 그 일에 맞는 싸고 빠른 클라우드 모델로 방향을 틀었습니다.

다음 글(기술 2)에서는 그 "기능마다 다른 클라우드 모델"의 비용·지연·품질을 표로 보여드립니다 — 검색 한 번에 얼마가 드는지, 왜 검색어 파싱만은 비싼 모델을 남겼는지, 그리고 영상 추출이 어떻게 10시간에서 15분이 됐는지.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.