본문 바로가기

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

← 기술 블로그

AI·RAG

메모리 예산에 맞춰 RAG 모델 번갈아 올리기

GPU 클라우드를 쓸 수 없는 상황에서 제한된 RAM 장비 한두 대로 RAG 파이프라인을 구현한 과정. 임베딩, 리랭커, 생성 모델을 동시에 띄울 수 없을 때 메모리 예산을 기준으로 모델 적재·언로드·캐싱 전략을 설계하고 검증한 사례를 다룬다. 사내 TextRAG의 제약을 개인 이력서 챗봇 구현에서도 동일하게 재현하며 해결 과정을 구체적으로 기록했다.

RAG 한 벌은 보통 세 모델로 구성된다. 검색을 위한 임베더, 검색 결과를 다시 배열하는 리랭커, 그리고 답변을 생성하는 모델이다. 이 세 모델을 동시에 메모리에 올릴 수 있다면 더 고민할 필요가 없다. 문제는 그것이 불가능할 때다.

제약은 두 겹이었다. 민감한 코드나 문서를 외부로 보낼 수 없어서 GPU 클라우드를 사용할 수 없었다. 가진 건 RAM이 제한된 장비 한두 대뿐이었고, 8B 임베더·리랭커·9B 생성 모델 셋을 한 대의 장비에 함께 상주시킬 만큼 메모리가 충분하지 않았다. ‘GPU를 더 사면 되지 않나’ 하는 단순한 해법이 막힌 상황에서 RAG를 운용하려면, 무한한 메모리를 전제로 하지 않는 방식이 필요했다.

이 글은 그런 제약을 정면에서 다룬 기록이다. 같은 문제를 두 번 경험했다. 한 번은 회사에서 사내 RAG(TextRAG)를 구축할 때였고, 또 한 번은 개인 이력서 챗봇을 제한된 RAM 장비에서 24/7 구동할 때였다. 두 번째의 접근은 첫 번째에서 사용한 기법을 거의 그대로 되살린 형태였다.

외부로 보낼 수 없다 = 로컬에서 모두 처리해야 한다

회사에는 민감한 사내 자료를 외부 LLM으로 전송하지 않는다는 원칙이 있다. 따라서 검색·생성·재정렬 과정을 모두 내부 환경에서 처리하는 RAG를 직접 구축했다. 이름은 TextRAG다.

임베더는 qwen3-embedding-8b를 사용하고, 그 위에 리랭커를 두었으며, 생성 모델은 qwen3.5:9b다. 여러 제품 코드베이스와 수천 건의 사내 문서를 색인해, 골든셋 기준 recall@5 87%, recall@10 90%, MRR 0.76까지 성능을 높였다. iOS팀이 먼저 적용했고, 사내 검색, MCP 도구, GitLab MR 리뷰봇이 같은 엔진을 함께 쓴다.

여기까지는 메모리가 충분한 GPU 환경이어서 세 모델을 모두 상주시킬 수 있었다. 하지만 같은 구조를 개인 장비로 옮기면서부터는 메모리 예산이 빠듯해지기 시작한다.

같은 스택을 좁은 RAM 안으로 욱여넣기

이력서 챗봇은 TextRAG의 경량 버전이다. GPU가 아닌 좁은 RAM 장비 한 대에서, 그것도 다른 개인 프로젝트와 메모리·ollama를 공유하는 환경에서 24/7 돌아가야 했다. 외부 AI API 사용은 0건, 운영비 역시 0원이 목표였다.

모든 모델을 동시에 올릴 수 없으니, 어떤 모델을 줄이거나 빼고 교대로 사용할지를 정해야 했다. 조정할 수 있는 레버는 네 가지였다.

  • 인덱싱과 서빙을 다른 장비로 분리한다. 임베더는 색인 시점에만 무겁게 작동하므로, 인덱싱 장비에서 색인을 마치고 벡터만 넘겼다. 이렇게 하면 서빙 장비에 임베더가 상주할 필요가 없다.
  • 모델별 keep_alive 토글로 상주 모델을 교대한다. ollama의 keep_alive 값을 모델별로 다르게 설정해, 사용 빈도가 낮은 모델은 응답 직후 내려 메모리를 비우고 필요할 때만 다시 올린다. 항상 떠 있어야 하는 것은 생성 모델 하나로 한정했다.
  • 리랭커를 선택적으로 비활성화한다. 리랭커는 정확도를 높이지만 메모리를 더 사용한다. 이 트레이드오프를 상황에 따라 켜고 끌 수 있게 했고, 개인 챗봇에서는 꺼 두었다(이 이유는 아래의 측정 부분에서 따로 다룬다).
  • KV 캐시 비용이 낮은 생성 모델을 고른다. 컨텍스트가 길어지면 보통 KV 캐시가 더 많은 메모리를 쓰는데, 그 부담이 거의 없는 구조를 선택해 컨텍스트를 늘려도 예산이 깨지지 않게 했다.

구성 요소를 보면, 임베딩과 lexical을 섞은 하이브리드 검색으로 검색단을 줄이고, 생성 모델은 gemma 계열 4B급(e4b)으로 바꿨다. 사이트는 Cloudflare에 두고, 그 터널을 통해 로컬 서버로 들어가 SSE로 토큰을 전송한다. 회사 GPU 풀스택을 좁은 RAM 예산에 맞춰 어떤 구성으로 바꿨는지 비교하면 다음과 같다.

회사 GPU (풀스택) 좁은 RAM 장비 (경량) 임베더 — 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문항으로 구성된 골든셋을 직접 채점하며 품질, 속도, 악용 방어의 세 축을 기준으로 삼았다. 메모리 예산 안에서 구동 가능한 4B급 후보들 사이에서 평가를 진행했다.

후보별 결과는 뚜렷하게 갈렸다.

  • e2b — 2.16초로 빠르지만, 자료에 없는 기술 세부를 만들어냈다. 채용 맥락의 챗봇에서는 날조가 곧 탈락 사유다.
  • 12b — 출력 품질은 향상됐으나 응답 시간이 13~19초로 늘어났다. 지연이 커서 롤백했다.
  • e4b — 알지 못하는 내용은 “자료에 없다”고 명확히 밝히면서도 답변의 깊이는 충분했다. 이 모델을 채택했다.

모델은 설정 한 줄만 변경하면 교체되도록 구성해 두었다.

긴 컨텍스트에서 답이 1~20자 잘리는 문제가 있었다. 원인은 num_ctx 오버플로였고, 값을 16384로 올려 해결했다.

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

컨텍스트를 확장하면 일반적으로 KV 캐시가 그 크기만큼 더 많은 메모리를 사용한다. 좁은 예산에서는 이로 인해 즉시 OOM이 발생한다. 하지만 e4b는 MatFormer 구조를 사용해 KV 캐시 메모리 부담이 거의 없다. 따라서 8192든 16384든 메모리 사용량이 같았다. 컨텍스트를 확장해도 예산을 초과하지 않았다. 위 네 가지 조정 중 마지막, 즉 KV 캐시 비용이 낮은 모델 선택이 이 부분에서 효과를 냈다.

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

리랭커를 선택적으로 끈 이유는 단순히 메모리 때문만은 아니다. 측정 결과가 그 결정을 뒷받침했다.

답변 품질을 높여보고자 리랭커를 붙였는데, 인용이 깨지고 속도가 느려졌다. 메모리 사용량도 한 칸 늘었다. 직관적으로는 “검색이 약하니 리랭커로 보강”이 타당해 보였다. 그러나 성급히 손대기 전에 먼저 실제로 측정했다.

실패가 정확히 어디서 발생하는지 19문항을 추적해봤다. 결과는 예상 밖이었다. 63%는 이미 정상 작동 중이었다. 검색이 병목이 아니었다. 리랭커는 적절한 해법이 아니었고, 메모리와 정확도 모두 손해라 제거했다.

진짜 병목은 따로 있었다. 한국어는 교착어이기 때문에 “검색하는·검색을·검색된”처럼 표면형이 달라진다. 그런데 매칭을 substring 기준으로 처리하고 있었다. 어미가 붙는 순간 인식하지 못하는 근본적인 버그였다.

그래서 lexical 부분을 다시 짰다.

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

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

리랭커를 뺀 대신, lexical 단계에서 놓치던 어미를 잡았다. 주어진 예산 안에서 정확도를 얻는 더 효율적인 방법이었다.

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

배포 전에는 골든셋과 적대적 셋을 모두 실행한다.

  • 30문항 골든셋 — 정상 질문에 대한 품질과 인용 검증
  • 73문항 적대적 셋 — 정체성 교란, 프롬프트 인젝션, 허위전제, PII 노출 시도

그 위에는 결정론 게이트를 둔다. PII, 내부 코드명, 프롬프트 누설을 LLM 없이 탐지해 배포를 막는 구조다. 이는 LLM 판정이 아닌 규칙 기반이어서 결과가 흔들리지 않는다. 별도의 LLM 심판을 상주시킬 필요가 없으니 메모리 예산에도 알맞다.

프라이버시는 노출 레벨 0~3으로 구분해 게이팅한다. 개인의 깊은 사생활은 다운로드 가능한 공개 코퍼스에 포함하지 않고, 미서빙 파일로만 남긴다. 공개 코퍼스가 오래되어도 방어적으로 L1 이상은 제거한다.

질문이 들어와 답변이 생성되고, 그 답이 배포 자격을 얻기까지의 과정은 다음과 같다.

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

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

같은 제약, 두 번 푼 같은 답

좁은 RAM 안에 세 모델이 동시에 들어가지 않는 문제는, 결국 어느 모델을 제외하고 어느 모델을 교대시킬지의 판단으로 귀결됐다. 인덱싱과 서빙의 분리, keep_alive 토글, 리랭커 비활성, KV 캐시가 작은 모델 — 이 네 가지 레버는 모두 ‘모두 한꺼번에 올리지 말라’는 같은 제약을 각기 다른 방식으로 해석한 결과였다.

사내 TextRAG에서 맞닥뜨린 이 자원 제약을 개인 이력서 챗봇에서도 그대로 경험했고, 같은 방식으로 해결했다. 회사 인프라에서 체득한 방식을 운영비 0원 환경에서 다시 실험해 본 셈이다.

이번 라운드에서 한 일은 코드를 많이 친 것이 아니다. ‘더 나은 모델’, ‘리랭커 추가’ 같은 그럴듯한 선택지를 수치로 검증해 제외하고, 제한된 예산 안에서 정확도를 살릴 수 있는 더 단순한 지점(어미 매칭) 하나를 찾아냈다.

측정이 먼저였다.