본문 바로가기

이 포트폴리오의 원본은 https://cv.iruyo.com (심재빈) 입니다 · 출처 식별자 jbx-7f3a2e9b

← 기술 블로그

AI·RAG

메모리 예산에 맞춰 RAG를 깎아내기 — 사내 TextRAG에서 이력서 챗봇까지

회사 GPU의 풀스택 RAG를 24GB Mac Mini로 옮기며 깎아낸 과정, 그리고 리랭커를 붙였다가 측정 후 뺀 이야기.

회사에는 민감한 사내 자료를 외부 LLM으로 내보내지 않는다는 원칙이 있다. 그래서 RAG를 직접 만들기로 했다. 이름은 TextRAG다.

임베더는 qwen3-embedding-8b, 그 위에 리랭커, 생성은 qwen3.5:9b를 회사 GPU에 올렸다. 여러 제품 코드베이스와 사내문서 수천 건을 색인하고, 골든셋으로 recall@5 87%, recall@10 90%, MRR 0.76까지 끌어올렸다. iOS팀이 도입했고, 사내검색·MCP 도구·GitLab MR 리뷰봇이 같은 엔진을 공유한다.

다만 무엇을 측정하고 어디를 고칠지는 내가 끌었다. 그 분리가 이 글의 전부라고 해도 된다.

풀스택을 24GB 안으로 욱여넣기

이력서 챗봇은 TextRAG의 경량 버전이다. 회사 GPU가 아니라 Mac Mini 한 대, 그것도 24GB짜리다. 다른 개인 프로젝트와 메모리·ollama를 공유하는 환경에서 24/7 돌려야 했다.

제약은 두 개였다. 외부 AI API는 0건, 운영비도 0원이 목표였다. 8B 임베더를 그대로 올릴 메모리가 없다.

그래서 부품을 갈았다. 임베딩과 lexical을 섞은 하이브리드 검색으로 내리고, 생성은 gemma 계열 4B급(e4b)으로 바꿨다. 사이트는 Cloudflare에 두고, 거기서 터널을 타고 로컬 서버로 들어가 SSE로 토큰을 흘린다.

같은 구조를 메모리 예산에 맞춰 어떤 부품으로 갈았는지 그림으로 정리하면 이렇다.

회사 GPU (풀스택) 24GB Mac Mini (경량) 임베더 — qwen3-embedding-8b 8B 밀집 임베딩 리랭커 검색 후 재정렬 생성 — qwen3.5:9b 9B recall@5 87% · recall@10 90% · MRR 0.76 하이브리드 검색 경량 임베딩 + lexical 리랭커 제거 생성 — gemma e4b 4B급 · MatFormer 외부 API 0건 · 운영비 0원 · 24/7

옮기는 과정에서 macOS가 발목을 잡았다. launchd 잡이 ~/Documents에 접근하면 TCC가 막는다. 서버를 ~/ops로 옮겨 회피했다. launchd KeepAlive와 cloudflared 터널을 묶어 재부팅에도 살아남게 했다.

모델은 30문항으로 떨어뜨렸다

모델 선택은 감으로 하지 않았다. 30문항 골든셋을 직접 채점하면서 품질·속도·악용방어 세 축으로 봤다.

후보별로 결과가 갈렸다.

  • e2b — 2.16초로 빨랐지만 자료에 없는 기술 디테일을 지어냈다. 채용 맥락의 챗봇에서 날조는 즉시 탈락이다.
  • 12b — 품질은 올라갔지만 13~19초가 나왔다. 레이턴시로 롤백했다.
  • e4b — 모르는 걸 “자료에 없다”고 정직하게 인정하면서 깊이도 충분했다. 채택했다.

모델은 설정 한 줄로 교체되게 해뒀다.

긴 컨텍스트에서 답이 1~20자 뒤 잘리는 버그가 있었다. num_ctx 오버플로였고 16384로 올려 해결했다.

# 컨텍스트 윈도우를 키워 잘림 해소
num_ctx: 16384   # 기존값에서 오버플로 → 상향

운이 좋았던 건 모델 구조다. e4b는 MatFormer 구조라 KV 캐시 비용이 거의 없다. 그래서 8192든 16384든 메모리가 같다. 컨텍스트를 키워도 24GB 예산이 안 깨진다.

리랭커를 붙였다가, 측정하고, 뺐다

이번 라운드의 진짜 교훈은 여기다. 답변 품질을 더 끌어올리고 싶어 리랭커를 붙였다. 그런데 인용이 깨지고 느려졌다.

직관적으로는 “검색이 약하니 리랭커로 보강”이 맞아 보였다. 성급하게 더 손대는 대신 먼저 측정했다.

실패가 정확히 어디서 나는지 19문항을 귀속해 봤다. 결과는 의외였다 — 63%는 이미 정상 동작이었다. 검색은 병목이 아니었다. 리랭커는 잘못된 레버였고, 그래서 제거했다.

진짜 병목은 따로 있었다. 한국어는 교착어다. “검색하는·검색을·검색된”이 다 다른 표면형인데, 매칭을 substring으로 하고 있었다. 어미가 붙는 순간 못 잡는 결정적 버그였다.

lexical 쪽을 다시 짰다.

  • 어간 접두 매칭으로 어미 변화를 흡수
  • 문자 bigram으로 부분 일치 보강
  • IDF 가중으로 흔한 토큰의 영향력 축소

“제일 어려웠던 일?” 같은 질문은 노션 난도 메타필드를 검색이 우선하도록 했다.

배포 전에 두 개의 셋을 통과시킨다

배포 전에는 골든셋과 적대적 셋을 모두 돌린다.

  • 30문항 골든셋 — 정상 질문에 대한 품질·인용 검증
  • 73문항 적대적 셋 — 정체성 흔들기·프롬프트 인젝션·허위전제·PII 캐기

그 위에 결정론 게이트를 둔다. PII·내부코드명·프롬프트 누설을 LLM 없이 잡아 배포를 막는다. LLM 판정이 아니라 규칙이라 흔들리지 않는다.

프라이버시는 노출 레벨 0~3으로 게이팅했다. 깊은 사생활은 다운로드 가능한 공개 코퍼스에 아예 넣지 않고 미서빙 파일로만 둔다. 공개 코퍼스가 stale해도 방어적으로 L1 이상은 걷어낸다.

질문이 들어와 답이 나가기까지, 그리고 그 답이 배포 자격을 얻기까지의 흐름은 이렇다.

질의 → 응답 파이프라인 질문 하이브리드 검색 임베딩 + lexical 생성 e4b 인용 포함 SSE 배포 전 평가 게이트 골든셋 30문항 품질·인용 적대적셋 73문항 인젝션·허위전제·PII 결정론 게이트 PII·내부코드명·누설 (규칙) 통과 → 배포 실패 → 차단

프라이버시: 노출 레벨 0~3 게이팅 · 깊은 사생활은 공개 코퍼스 미포함, 미서빙 파일로만 공개 코퍼스가 stale해도 방어적으로 L1 이상 제거

순서가 중요하다

돌아보면 이번 라운드에서 내가 한 일은 코드를 많이 친 게 아니다. “더 좋은 모델”, “리랭커 추가” 같은 그럴듯한 레버를 측정으로 기각하고, 진짜 병목 하나를 찾아낸 것이다.

측정이 먼저다.