2개의 GTX 1080 Ti와 RTX 3090으로 완전 로컬 연구용 RAG 구축 — 3가지 함정
출처: Dev.to
내 논문에 대해 클라우드 API에 올리지 않고 질문하고 싶었다. 이것이 바로 그 과정을 기록한 이야기다—하이브리드 검색과 재정렬을 결합한 완전 오프라인 개인 RAG를 오래된 GPU 여러 대와 최신 GPU 한 대로 구축한 과정. 세 가지 문제에 하루 이상을 쏟았으며, 예상과는 전혀 달랐다.
목표: 내 논문에 대한 개인 RAG
나는 PDF 폴더를 가지고 있는 연구자다. 이 파일들을 호스팅된 API에 올릴 수 없고(또는 올리고 싶지 않다). 그 코퍼스에 대해 자연어로 인용된 답변을 내 하드웨어만으로 얻고 싶었다. 그래서 나는 paper-rag라는 약 200줄짜리 파이썬 도구를 만들었고, 전체 스택을 로컬에 두었다:
PDFs → chunk → BGE-M3 dense (Ollama) ┐
BM25 sparse (fastembed)┴→ Qdrant (embedded, on disk)
│
question → dense + sparse → RRF fusion → cross-encoder rerank → top passages
│
▼
local LLM (Ollama) → cited answer
밀도 임베딩은 의미를 잡아내고, BM25 스파스는 정확한 용어 (유전자 이름, 식별자 등)를 잡아낸다. 두 가지를 융합하고 교차 인코더로 재정렬하면 LLM이 코사인 유사도만 사용할 때보다 훨씬 더 좋은 컨텍스트를 얻게 된다. 서버도, API 키도 없으며, 데이터는 절대 외부로 나가지 않는다.
내가 가지고 있던 하드웨어는 2× GTX 1080 Ti(Pascal, 2017, 11 GB each) 한 대와, 나중에 별도로 마련한 RTX 3090(24 GB, Ampere) 한 대였다. 오래된 카드와 최신 카드를 섞은 조합에서 교훈이 나왔다.
Gotcha 1: 임베딩 모델이 GPU 전체를 멈추게 했다
1080 Ti 머신(WSL2 위에서 실행)에서 긴 인제스트 작업을 하면 BGE-M3 임베더가 멈추었다—그리고 우아하게 종료되지 않았다. llama-server 프로세스가 D 상태(interruptible하지 않은 상태)로 빠졌고, nvidia-smi 자체도 응답을 멈췄으며 GPU 전체가 걸렸다. 어떤 신호도 프로세스를 죽일 수 없었고, wsl --shutdown을 완전히 실행해야만 복구되었다.
먼저 잘못된 원인을 추적했다:
- “배치 사이즈가 문제다.” 아니다. 모델이 고장 난 뒤에는 600 문자짜리 하나의 청크조차 90초 안에 타임아웃이 발생했다.
- “새로운 Ollama 버전이면 해결될 것이다.” 변경 로그를 확인했지만, 다음 몇 차례 패치에서는 전혀 관련 없는 모델 충돌만 고쳤다.
실제 해결책은 해당 작업에 GPU를 전혀 사용하지 않는 것이었다. BGE-M3는 아주 작다(≈1 GB), 그래서 CPU에 고정하고 LLM만 GPU에 남겨두었다:
printf 'FROM bge-m3\nPARAMETER num_gpu 0\n' > bge-m3-cpu.Modelfile
ollama create bge-m3-cpu -f bge-m3-cpu.Modelfile
RAG_EMBED=bge-m3-cpu python rag.py ingest ./papers # 임베딩은 CPU, 답변은 GPU
약 100개의 청크 코퍼스가 CPU에서 1분 이내에 임베딩되고, GPU는 다시는 멈추지 않는다. 교훈: WSL 위의 오래된 Pascal 카드에서는 임베딩 경로가 가장 취약하며, 임베더 자체는 GPU가 필요하지 않다.
Gotcha 2: 27B 모델이 컨텍스트를 제한하기 전까지 절반 속도로 동작했다
3090에 qwen3.6:27b(27.8 B 파라미터, Q4, 약 17.4 GB 가중치)를 로드했을 때 ~17 토큰/초가 나왔다. 24 GB VRAM에 들어가는 27 B 모델이라면 이 속도는 이상했다.
ollama ps를 보면 모델이 24.6 GB에 로드됐고, 약 4 GB가 CPU로 스필되었다. 하지만 가중치는 17.4 GB뿐인데 나머지 7 GB는 KV 캐시가 차지하고 있었다. 이 모델은 256K 토큰 네이티브 컨텍스트를 지원하도록 설계됐으며, Ollama는 이를 맞추기 위해 캐시를 크게 잡아 VRAM을 초과해 오프로드가 발생했고, 그 결과 전체 속도가 제한되었다.
실제로 사용하는 컨텍스트 크기로 제한하면 모든 것이 카드에 들어온다:
RAG_NUM_CTX=8192 python rag.py ask "..." # 또는 API 호출 시 options.num_ctx
결과: GPU 전용 100 %, ~36 토큰/초—2배 빠른 속도—이며 RAG 크기의 프롬프트에서는 전혀 손해가 없다. 교훈: 네이티브 컨텍스트가 크게 잡혀 있으면 실제 사용량과 무관하게 VRAM을 잡아먹는다. num_ctx를 실제 작업 크기로 설정하라.
Gotcha 3: 오래된 카드와 새로운 카드를 섞어서는 안 된다
유혹적인 아이디어: 2× 1080 Ti(총 22 GB)와 3090(24 GB)를 하나의 거대한 추론 클러스터로 합친다. 가능하다(예: llama.cpp RPC 백엔드, exo). 하지만 권장되지 않는다.
두 머신은 1 GbE LAN으로 연결돼 있다—NVLink이나 PCIe에 비해 수십 배 느리다. 머신 간 텐서 병렬화는 이 링크가 병목이 되고, 느린 Pascal 카드가 빠른 Ampere 카드를 그 수준까지 끌어내린다. 모델이 어느 한 박스에도 들어가지 않을 정도로 거대할 때만 병합이 의미가 있을 뿐이며, 그 경우에도 속도는 느리다.
실제로 효과적인 방법은 역할 분담이다:
- 3090 → 지연에 민감한 작업: LLM 생성 + 재정렬 + 질의 임베딩
- 1080 Ti 박스 → 처리량/배치 작업: 대량 코퍼스 임베딩(CPU 사용, Gotcha 1) 및 인제스트
작은 환경 변수 하나로 임베딩은 한 박스로, LLM은 다른 박스로 지정한다:
OLLAMA_URL=http://gpu-box:11434 RAG_LLM=qwen3.6:27b \
RAG_EMBED_URL=http://127.0.0.1:11434 RAG_EMBED=bge-m3-cpu \
python mcp_server.py
두 박스 모두 동일한 bge-m3를 사용하므로 밀도 벡터가 호환된다—한 머신에서 인제스트하고 다른 머신에서 서비스할 수도 있다. 각 박스에서 ollama ps를 확인하면 오래된 박스는 CPU에서 bge-m3-cpu가, 3090은 27 B 모델이 실행 중임을 알 수 있다. 두 머신이 서로 다른 작업을 병렬로 수행함으로써 하나의 모델을 두고 경쟁하지 않는다.
결과: 이제 실제로 유용하다
하이브리드 검색 + 재정렬은 컨텍스트 품질을 눈에 띄게 높이며, 그 결과 인용된 답변이 더 깔끔해진다. 각 주장마다 출처 페이지가 태그로 붙고, 검색된 컨텍스트에 답변이 없을 경우에는 억지로 만들지 않고 “답을 찾을 수 없습니다”라고 말한다.
또한 도구가 MCP를 지원하므로 에이전트(Claude, Cursor 등)에서 호출할 수 있다. search_papers와 ask_papers가 툴로 나타나 에이전트가 내 코퍼스를 검색하고 인용한다—여전히 완전 로컬이다.
조용히 원하는 이유는, 작은 양자화 모델
