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

t1-rerank-ndcg

서비스 만들기 삽질기와 기술 삽질기를 병행해서 올리고 있습니다. 이번 글은 기술 삽질기 입니다.

"내 GPU"의 정체 — MacBook Pro M3 Max

이번 서비스를 만들면서 local llm을 경험해보고자, 맥미니 64GB 모델을 구매하려고 개인적으로 예산을 확보하는 중이었는데 단종되어 버리는 어처구니 없는 상황을 접하고, 맥스튜디오 64GB 모델은 가격이 이 세상 가격이 아닌 천상계 가격으로 올라가는 상황에서 어쩔수 없이 선택한 장비가 맥북 프로 M3 Max 입니다.

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

  • MacBook Pro, Apple M3 Max, 통합 메모리(unified memory) 36GB, macOS
  • 런타임: Ollama 0.30.8 (macOS LaunchAgent로 상주, 기본 127.0.0.1:11434)
  • LAN 구성: LLM 연산기(M3 Max)와 MongoDB 호스트는 서로 다른 맥북입니다. 운영/다른 장비에서 이 M3 Max의 Ollama를 쓸 때는 http://<LAN-IP>:11434/v1 로 직접 붙이거나 Cloudflare Tunnel로 노출했습니다.

핵심 단어는 "통합 메모리" 입니다. 게이밍 PC에 꽂는 NVIDIA 그래픽카드는 GPU 전용 메모리(VRAM)가 24GB면 24GB를 오롯이 모델에 씁니다. 그런데 Apple Silicon은 CPU·GPU·시스템이 같은 36GB를 공유합니다. LLM을 돌리면 모델 가중치와 KV 캐시가 이 공유 메모리에 올라가는데, 여기에 검색 인덱스·웹서버·(같은 랜의) DB까지 얹히면 "내 몫"이 순식간에 줄어듭니다. 이 공유 구조가 4편에서 겪은 메모리 벽의 진짜 원인이었습니다.

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

로컬 모델별 메모리 발자국과 속도를 재보면 이렇습니다.

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

30B 모델이 "MoE(Mixture of Experts), 활성 3B"라는 점을 짚어둘 만합니다. MoE는 전체 파라미터는 30B지만 한 번에 3B만 켜서 계산이 빠른 구조입니다(그래서 ~65 tok/s가 나옵니다). 그런데 메모리는 전체 30B 가중치를 다 올려야 해서 여전히 ~18GB를 먹습니다. "빠른데 무겁다" — 이게 로컬 대형 모델의 딜레마였습니다.

문제는 이 서비스가 여러 모델을 동시에 필요로 한다는 겁니다. 문서를 좌표로 바꾸는 임베딩 모델은 항상 상주해야 하고, 장소 추출엔 큰 모델이, 검색 이유 생성엔 또 다른 모델이 붙습니다. 그런데:

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

여기에 검색 인덱스, 웹서버까지 얹으면 36GB로는 감당이 안 됩니다. 실제로 "30b + 임베딩 + 14b 동시 상주"를 시도하자 OOM(메모리 부족) 이 났고, 결국 타협은 "단일 모델만 상주" 였습니다 — 예컨대 14b 단일(9.3GB) + 임베딩 4b(2.5GB) = 11.8GB 정도로 묶어 스왑을 피하는 식이었죠. 큰 모델(30b)을 쓰면 다른 걸 못 올리고, 작은 모델(8b)로 내리면 품질이 떨어지는 — 4편의 "작은 차고, 큰 엔진"이 바로 이 숫자였습니다.

곁다리 사고 — 끄지 못한 '생각하기'

메모리 말고도 로컬 특유의 함정이 있었습니다. qwen3 계열은 답하기 전 혼자 '생각(thinking/reasoning)'하는 모드가 기본입니다. qwen3:14b를 thinking-on으로 두면 간단한 판정 하나에 58초가 걸렸습니다. 우리처럼 "이 집 한옥이야 아니야" 같은 단순 분류엔 이 생각이 8~10배 느림만 유발합니다.

당연히 껐죠. 그런데 OpenAI 호환 API로 끄는 스위치를 눌렀더니 모델이 빈 답을 돌려줬습니다. 알고 보니 우리가 쓰던 연결 방식(프로토콜)이 그 스위치를 제대로 전달 못 하는 구조였고, 결국 Ollama 네이티브 API(/api/chat + think:false) 로 통로 자체를 바꿔서야 "생각하지 말고 바로 답해"가 전달됐습니다. (이 "프로토콜 인지형 클라이언트"는 나중에 클라우드로 갈아탈 때 그대로 재활용됩니다 — 기술 2편.)

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

메모리·속도는 그렇다 쳐도, 가장 뼈아팠던 건 품질이었습니다. "검색 결과를 LLM으로 재정렬(rerank)하면 좋아지겠지"라는 기대로 로컬 모델들을 nDCG@10로 A/B했습니다.

> nDCG@10(normalized Discounted Cumulative Gain) = "상위 10개에 정답을 얼마나 위쪽에 잘 배치했나"를 0~1로 재는 표준 지표. 1에 가까울수록 좋음.

같은 정답셋(v3, 비-SYN 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

숫자가 말해줬습니다. 로컬 모델 중 cosine(단순 유사도 순위)을 유의미하게 이기는 건 30B(+0.042)뿐인데, 그건 20초·18GB라 사용자 검색에 못 씁니다. 나머지 8b·14b·gemma·exaone은 오히려 cosine보다 못하거나 동률이었습니다. 심지어 14b는 재정렬을 시키면 119개 중 44개에서 출력 파싱이 깨지는 불안정성까지 있었습니다.

혹시 정답셋이 편향된 건 아닐까 싶어, 판정자를 바꾼 공정 골드로 재검증했지만 결론은 그대로였습니다(none 0.500 · 30b 0.535 · haiku 0.509 — 순서 동일). "LLM으로 재정렬"이라는 그럴듯한 아이디어가, 재보니 로컬에선 돈(시간·메모리)만 쓰고 이득이 없었던 겁니다.

그럼 로컬 모델은 어디에 남겼나 — matchReason A/B

재정렬엔 못 써도, 검색 이유 한 줄(matchReason) 생성에는 로컬 모델도 쓸 만한지 따로 A/B했습니다.

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

exaone은 2배 빨랐지만 "최고!·필수!" 같은 홍보어를 15개 중 8개에서 남발하고 길이 제한도 어겼습니다. 반대로 qwen3:14b는 느려도 규율을 지켰죠. 다만 이마저도 실서비스에선 2건만 붙여도 10초를 넘겨(직렬 처리) 결국 비동기 프리페치(상위 5건, 3건씩 배치, 별도 30초 타임아웃)로 감쌌습니다. 로컬로 "충분히 좋게"는 되는데 "충분히 빠르게"가 안 됐습니다.

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

의외로 임베딩(문서·질의를 좌표로 바꾸는 모델) 은 로컬이 클라우드에 밀리지 않았습니다. 로컬 qwen3-embedding:4b(2560차원)와 클라우드 text-embedding-v4(2048차원)의 정밀도(precision@10)를 재보면:

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

거의 동급(오히려 로컬이 약간 높음)이라, "임베딩은 로컬로 둘까"도 진지하게 검토했습니다. 단 함정이 하나 있었는데 — 클라우드 임베딩을 OpenAI 호환 엔드포인트로 부르면 text_type(질의/문서 구분)이 안 실려 품질이 붕괴(INTENT 0.32) 했습니다. 반드시 네이티브 API + 2048차원 + 질의/문서 동일 모델을 지켜야 제 성능이 나옵니다. (임베딩은 결국 운영 편의를 위해 클라우드로 통일했지만, "로컬이 못해서"는 아니었습니다.)

이 글의 결론

  • 로컬 LLM의 벽은 "느리다"가 전부가 아닙니다. 통합 메모리 공유 구조에서 여러 모델을 동시에 못 올리는 것(30GB+ vs 36GB)이 더 근본적인 제약이었고, thinking 모드처럼 프로토콜을 바꿔야 겨우 끄는 운영 함정들이 겹쳤습니다.
  • "LLM 재정렬"처럼 직관적으로 좋아 보이는 기법도, 자체 A/B로 재보면 로컬에선 cosine을 못 이길 수 있습니다. 온라인 가능한 로컬 모델 중 cosine을 이긴 건 하나도 없었습니다. 측정이 없었으면 무거운 로컬 리랭커를 넣고 "품질 개선했다"고 착각했을 겁니다.
  • 그래서 리랭커의 역할을 "재정렬"이 아니라 "부적합 컷 + 검색 이유 생성" 으로 재정의하고, 그 일에 맞는 싸고 빠른 클라우드 모델로 방향을 틀었습니다.

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


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