GPU, 클라우드, Docker 없는 온프레미스 RAG: 각각 일주일씩 소요된 다섯 가지 교훈
요약
GPU, 클라우드, Docker를 사용할 수 없는 제한적인 온프레미스 환경에서 RAG 스택을 구축하며 겪은 실무적 교훈을 공유합니다. Windows Server와 CPU 기반 환경에서 Ollama, Qdrant, Mistral 7B 등을 활용해 시스템을 구현한 사례를 다룹니다.
핵심 포인트
- GPU와 클라우드 없이 CPU 전용 환경에서도 RAG 구축 가능
- Docker 사용이 제한된 Windows Server 환경에서의 운영 전략
- gRPC 통신 문제 해결을 위해 Qdrant REST API 직접 사용 권장
- 공공 및 의료 분야의 레거시 인프라 제약 사항 고려 필요
제가 읽은 모든 RAG 튜토리얼은 동일한 두 가지 가정을 전제로 합니다. 바로 당신에게 GPU가 있고, 클라우드 API를 호출할 수 있다는 것입니다. 제가 구축하는 환경에서는 이 두 가지 가정 모두 틀렸습니다.
저는 공공 부문의 의료 정보 시스템을 다룹니다. 스택은 기관 인프라 내부에서 실행되어야 하며(데이터가 네트워크를 벗어나면 안 됨), 제가 할당받는 하드웨어는 2년 전 조달 주기에 따라 생산된 무엇이든 됩니다. 실제로 이는 Windows Server, CPU 전용, 그리고 로컬에서 실행되는 오픈 웨이트 모델 (open-weight models)을 의미합니다.
그래서 저는 GPU, 클라우드, Docker 없이 완전히 온프레미스 (on-premise)에서 실행되는 RAG 스택을 구축했습니다. 이 프로젝트는 github.com/psychohub/rag-onpremise에서 오픈 소스로 공개되어 있습니다. 오케스트레이션 (orchestration)을 위한 ASP.NET Core 9, 로컬 추론 (inference)을 위한 Ollama, 벡터 (vectors)를 위한 Qdrant, 인제스트 파이프라인 (ingest pipeline)을 위한 Python, LLM으로서의 Mistral 7B, 그리고 임베딩 (embeddings)을 위한 nomic-embed-text를 사용합니다.
이를 프로덕션 (production) 환경에 적용하는 것은 설계보다 더 오래 걸렸습니다. 어떤 튜토리얼에서도 경고하지 않았던 다섯 가지 문제가 발생했기 때문입니다. 이것은 현장 보고서입니다.
환경, 그리고 그것이 중요한 이유
교훈을 나누기에 앞서, 제약 사항을 정확히 짚고 넘어갈 가치가 있습니다. 왜냐하면 제약 사항에 따라 무엇이 "좋은" 것인지가 달라지기 때문입니다.
스택은 Linux 워크스테이션이 아닌 Windows Server에서 실행되어야 합니다. 많은 대상 장비에서 Docker를 사용할 수 없습니다. 승인을 받지 못했거나, GPO 정책이 이를 제한하거나, 또는 운영 팀이 이미 모든 것을 Windows 서비스로 실행하고 있어 컨테이너 런타임 (container runtime)을 추가하는 것이 아무도 책임지고 싶어 하지 않는 새로운 운영 영역을 만드는 것이기 때문입니다. GPU는 희망 사항일 뿐입니다. 그동안 당신은 CPU 추론 (inference)을 사용해야 하며, 이를 어떻게든 작동시켜야 합니다.
이 중 어느 것도 특이한 것이 아닙니다. 이는 많은 공공 부문, 의료 및 레거시 엔터프라이즈 (legacy enterprise) 환경의 기본 현실입니다. 또한 인터넷상의 대부분의 RAG 콘텐츠가 조용히 무시하고 있는 현실이기도 합니다.
시스템의 전체적인 형태:
Documents (PDF / Word / Excel)
│
▼
...
교훈 1: Qdrant .NET SDK는 gRPC를 사용합니다. 직접 REST를 사용하세요.
가장 먼저 시도한 것은 .NET용 공식 Qdrant SDK였습니다. 깔끔한 API, 잘 작성된 문서 덕분에 올바른 선택처럼 느껴졌습니다. 하지만 이 방식 또한 진단하는 데 하루가 걸릴 정도로 실패했는데, 그 실패가 명확하지 않았기 때문입니다. 연결은 수립되었으나 곧 끊어졌고, 에러 메시지는 실제 원인을 제외한 모든 것을 가리키고 있었습니다.
원인은 다음과 같았습니다. SDK는 gRPC를 통해 Qdrant와 통신하는데, .NET 애플리케이션과 Qdrant 인스턴스 사이의 네트워크 경로가 HTTP/1.1만 지원했습니다. gRPC에는 HTTP/2가 필요합니다. 중간에 있는 어떤 프록시(Proxy)나 로드 밸런서(Load Balancer)가 연결을 다운그레이드했고, SDK는 이를 우아하게 처리(Graceful degradation)하지 못하고 그냥 실패해 버린 것입니다.
해결책은 SDK를 완전히 건너뛰고 HttpClient를 사용하여 Qdrant의 REST API와 직접 통신하는 것이었습니다:
// 이렇게 하지 마세요 — SDK는 내부적으로 gRPC를 사용합니다
// var client = new QdrantClient(new Uri(url));
...
Qdrant의 REST API는 RAG 워크로드(Workload)를 수행하기에 충분히 완전합니다. 타입 안정성(Type safety)과 일부 사용 편의성(Ergonomics)을 잃는 대신, 네트워크의 모든 구간에서 HTTP/2 지원 여부를 두고 논쟁할 필요 없이 배포할 수 있는 능력을 얻게 됩니다. 기업용 또는 공공 부문 네트워크 환경에서는 충분히 가치 있는 거래입니다.
교훈 2: 기본 HttpClient 타임아웃이 응답을 끊어버릴 것입니다.
.NET의 HttpClient는 기본적으로 100초의 타임아웃(Timeout)을 가집니다. 대부분의 HTTP 작업에는 문제가 없습니다. 하지만 상대측이 CPU에서 실행 중인 Mistral 7B라면 이야기가 달라집니다.
적당한 사양의 서버(4 vCPU, 16 GB RAM)에서 Mistral 7B가 전체 응답을 생성하는 데는 60초에서 120초가 소요됩니다. 처음으로 엔드 투 엔드(End-to-end) 쿼리를 실행했을 때는 성공했습니다. 두 번째도 성공했습니다. 하지만 세 번째 실행 시, 모델이 우연히 더 긴 답변을 생성하게 되었고 클라이언트는 스트림 중간에 타임아웃이 발생했습니다. 그 결과 사용자는 일반적인 에러 메시지만 바라보게 되었고, 서버는 아무도 보지 못할 답변을 계속 생성하고 있었습니다.
해결책은 단 두 줄이면 됩니다:
// 이렇게 하지 마세요 — 기본값 100초로 인해 연결이 끊깁니다
var client = new HttpClient();
...
숫자 그 자체보다 중요한 것은 규율입니다. 만약 CPU에서 로컬 LLM (Large Language Model)을 호출하고 있다면, 실제 하드웨어에서 최악의 경우(worst case)를 측정하고 그보다 여유 있게 타임아웃 (timeout)을 설정하세요. 그리고 이 위에 UI (User Interface)를 구축하고 있다면, 진행 상태 표시기 (progress indicator)를 넣으세요. 90초 동안의 침묵은 시스템이 설계된 대로 정확히 작동하고 있을 때조차 마치 고장 난 시스템처럼 보입니다.
교훈 3: Ollama는 기본적으로 localhost에서만 리스닝(listening)합니다.
이 문제는 노트북에서 개발하다가 서버로 배포하는 과정에서 발견했는데, 다른 머신에 있는 .NET 애플리케이션이 Ollama에 접속할 수 없었습니다.
Ollama는 기본 설정 상태에서 127.0.0.1:11434에 바인딩(bind)됩니다. 로컬 개발에는 괜찮습니다. 하지만 LLM 호스트가 애플리케이션 호스트와 분리되어 있거나, 애플리케이션이 대화형 사용자와 루프백(loopback) 컨텍스트를 공유하지 않는 서비스 계정(service account) 하에서 실행되는 배포 환경에서는 무용지물입니다.
해결 방법은 환경 변수(environment variable)를 사용하는 것입니다:
$env:OLLAMA_HOST = "0.0.0.0:11434"
ollama serve
방법을 알고 나면 간단합니다. 함정은 Ollama가 접속 불가능할 때 내뱉는 에러 메시지가 "이봐요, 저는 루프백에서만 리스닝하고 있어요"가 아니라 일반적인 연결 에러(connection error)라는 점입니다. 저는 바인딩을 확인하기 전에 방화벽 규칙을 읽으며 오후 시간을 통째로 날렸습니다.
만약 Ollama를 Windows 서비스로 배포한다면 — 아마 그렇게 해야 할 것입니다 — 해당 환경 변수는 사용자 수준이 아닌 서비스 수준에서 설정되어야 합니다. PowerShell 프롬프트에서 설정하는 것은 서비스에 영향을 주지 않습니다. 사소한 디테일이지만, 실제 소요되는 비용은 큽니다.
교훈 4: Python MSI 설치 프로그램은 기업용 GPO 환경에서 실패합니다. 임베디드 패키지(embeddable package)를 사용하세요.
인제스트 파이프라인 (ingest pipeline)은 Python으로 작성되었습니다. 어떤 설치 프로그램이 실행될 수 있는지를 기업용 그룹 정책 개체 (GPO, Group Policy Objects)가 제어하는 보안이 강화된 Windows Server에서는 표준 Python MSI가 설치되지 않았습니다. 디스크에 아무것도 남지 않은 채 조용히 "작업이 완료되었습니다"라고 뜨거나, 실제 관리자 계정으로도 해결할 수 없는 권한 상승 (elevation) 관련 에러가 발생하는 등 다양한 방식으로 실패했습니다.
처음에는 명확하게 드러나지 않는 해결책은 바로 **임베더블 Python 패키지 (embeddable Python package)**를 사용하는 것입니다. 이것은 설치 프로그램(installer)이 아닌 ZIP 파일이므로, 대부분의 그룹 정책(GPO) 영향 범위를 우회할 수 있습니다.
설정 과정은 설치 프로그램보다 약간 더 수동적입니다:
- python.org에서
python-3.x.x-amd64-embed.zip을 다운로드합니다. C:\Python311\등 원하는 폴더에 압축을 풉니다.- 해당 폴더에서
python3xx._pth파일을 열고import site줄의 주석을 해제합니다. 이 작업이 없으면pip가 작동하지 않습니다. get-pip.py를 다운로드한 후, 해당 폴더에서python get-pip.py를 실행합니다.- 그 이후부터는
pip install -r requirements.txt가 정상적으로 작동합니다.
여기서 어려운 점은 없습니다. 단지 기본 경로로 문서화되어 있지 않을 뿐이므로, 이 방법이 존재한다는 사실을 모른다면 결코 성공할 수 없는 설치 프로그램과 싸우며 이틀을 허비하게 됩니다.
교훈 5: 품질의 대부분은 프롬프트(prompt)에 달려 있다.
저는 청킹 (chunking), 임베딩 (embedding) 파라미터, 그리고 검색 상위 K (retrieval top-K)를 튜닝하는 데 몇 주를 보냈고, 그때마다 한 자릿수 퍼센트의 개선만을 얻었습니다. 그러다 프롬프트 템플릿 (prompt template)을 다시 작성하자, 검색 튜닝 결과가 반올림 오차처럼 보일 정도로 응답 품질이 비약적으로 향상되었습니다.
제가 계속해서 오갔던 두 가지 실패 유형은 다음과 같습니다:
너무 제한적인 경우 (Too restrictive). "오직 문맥(context)에 기반해서만 답변하세요. 문맥에 답이 없다면 모른다고 답하세요." 모델이 문맥에 대해 거부 반응을 보였습니다. 부분적으로 다뤄진 질문에 대해서도 답변을 거부하고, 합리적인 추론을 거부하며, 동일한 문서를 읽는 사람이라면 누구나 답할 수 있는 질문에 대해서도 사용자에게 "모릅니다"라는 말을 남발했습니다.
너무 허용적인 경우 (Too permissive). "질문에 답하는 데 문맥을 활용하세요." 모델이 자신감 있게 환각 (hallucination)을 일으키기 시작하여, 검색된 청크 (chunks) 사이의 빈틈을 그럴듯하게 들리는 허구로 채워 넣었습니다. 규제가 엄격한 환경에서 이것은 품질의 문제가 아니라 법적 책임 (liability)의 문제입니다.
결과적으로 효과가 있었던 방식은 대략 다음과 같습니다:
제공된 문맥에 기반하여(BASED on) 답변하세요.
정보가 부분적으로 관련이 있다면, 그것을 사용하되
문맥이 말하는 것과 말하지 않는 것을 명확히 밝히세요.
...
중요했던 키워드는 "부분적으로 관련이 있음" (불완전한 문맥으로부터 추론할 수 있는 허용)과 "문맥이 말하는 것과 말하지 않는 것을 명확히 하라" (모델이 읽은 것과 추론한 것을 구분하도록 강제함)였습니다. 이 중 어느 것도 마법 같은 주문은 아닙니다. 하지만 이 두 가지가 결합되어, 균형을 "답변 거부" 및 "허위 정보 생성 (hallucination)" 상태에서 "가능할 때는 답변하고, 불가능할 때는 유보하며, 어떤 부분인지 알려주는" 상태로 옮겨 놓았습니다.
CPU 추론(Inference)의 실제 모습
튜토리얼에서 생략하는 또 다른 것: 바로 숫자입니다. 위의 모든 내용은 여러분이 감내할 수 있는 수준의 지연 시간 (latency)을 가정합니다. 제가 보유한 하드웨어에서 실제로 측정한 결과는 다음과 같습니다:
| 하드웨어 | 모델 | 응답 시간 |
|---|---|---|
| 4 vCPU / 16 GB RAM | Mistral 7B | 60–120초 |
| ... | ||
| CPU 행은 제가 실제로 배포하는 서버에서 지속적으로 측정한 값입니다. GPU 행은 빌린 하드웨어에서 단 한 번 테스트한 결과이며, 지속적인 운영 환경의 측정값이 아닙니다. 따라서 GPU 수치는 약속이 아닌 참고용으로만 보시기 바랍니다. |
두 가지 짚고 넘어갈 점이 있습니다. 첫째, 적당한 사양의 하드웨어에서 실행되는 phi3:mini는 훨씬 더 좋은 사양의 하드웨어에서 실행되는 Mistral 7B와 비교했을 때 지연 시간 측면에서 경쟁력이 있습니다. 품질 기준이 허용한다면, 하드웨어를 업그레이드하기 전에 모델의 사양을 낮추십시오. 둘째, CPU에서 GPU로 넘어갈 때의 성능 향상은 대략 10배입니다. 환경에 8 GB GPU 하나라도 구축할 수 있다면 반드시 그렇게 하십시오. 이는 가능한 상호작용의 차원을 바꿔 놓습니다.
CPU 지연 시간의 한계 때문에, 이 리포지토리(repo)에는 LLM 앞에 **시맨틱 캐시 (semantic cache)**를 포함하고 있습니다. 이는 들어오는 쿼리와 캐시된 쿼리 사이의 코사인 유사도 (cosine similarity)를 측정하며, 임계값 (threshold)은 0.92로 설정되어 있습니다. 사용자가 이전 쿼리와 의미론적으로 유사한 질문을 하면, 1초 미만 안에 캐시된 답변을 받게 됩니다. 새로운 질문을 하면 모델의 응답을 기다려야 합니다. 적당히 사용량이 있는 내부 시스템에서 캐시 적중률 (cache hit rate)은 평균적인 사용자 경험이 합리적이라고 느껴질 정도로 충분히 높았으며, 최악의 경우 여전히 90초가 소요되기는 했습니다.
시스템을 망가뜨리며 배운 한 가지 경고는 다음과 같습니다: LLM (Large Language Model)을 변경할 때는 캐시를 비워야 합니다. 캐시된 답변은 그것을 생성한 모델에 고정되어 있습니다. Mistral을 더 최신 모델로 교체하면, 캐시는 더 이상 실행 중이지 않은 모델의 답변을 반환하게 되며, 사용자는 개발자보다 먼저 성격(personality)의 변화를 눈치챌 것입니다.
오늘 시작하는 사람에게 해주고 싶은 말
제한된 하드웨어에서 온프레미스 RAG (Retrieval-Augmented Generation)를 구축하고 있다면, 요약하자면 다음과 같습니다:
- gRPC 대신 REST를 통해 Qdrant와 통신하세요. 기업용 네트워크 환경에서 예상치 못한 문제를 줄일 수 있습니다.
- HTTP 타임아웃(timeout)을 명시적으로 설정하세요. 기본값은 CPU 기반의 로컬 LLM이 아니라 웹 트래픽을 위해 설계되었습니다.
- Ollama의 바인딩(binding)을 노트북이 아닌 배포 환경에 맞춰 구성하세요. 서비스를 실행 중이라면 서비스 수준에서 환경 변수를 설정해야 합니다.
- 보안이 강화된 Windows 환경에서는 Python의 임베디더블 패키지(embeddable package)를 사용하세요. GPO (Group Policy Object) 환경에서 MSI 설치 파일은 도움이 되지 않습니다.
- 검색(retrieval) 전에 프롬프트(prompt)를 튜닝하세요. 청킹(chunking)과 Top-K도 중요하지만, 품질의 기준선이 실제로 결정되는 곳은 프롬프트입니다.
- LLM이 느릴 때는 공격적으로 캐싱하고, 모델을 변경할 때는 반드시 캐시를 무효화(invalidate)해야 함을 기억하세요.
- 하드웨어를 업그레이드하기 전에 모델의 사양을 낮추세요. 8GB RAM에서 실행되는
phi3:mini가 감당할 수 없는 비용의 머신에서 돌아가는 Mistral 7B보다 낫습니다.
이 중 어느 것도 생소한 내용은 아닙니다. 이는 튜토리얼이 GPU, 클라우드, 그리고 Linux 개발 환경을 당연하게 가정할 때 생략되는 RAG의 실무적인 부분입니다. 이 중 어느 것도 갖추지 못했다면, 이것이 당신이 맞닥뜨릴 현실입니다.
제 로드맵의 다음 단계는 스페인어 임상 텍스트에 대한 적절한 임베딩(embedding) 평가입니다. 왜냐하면 "작동한다"는 것과 "당신의 언어와 코퍼스(corpus)에서 잘 작동한다"는 것은 같은 의미가 아니며, 저는 아직 그 격차를 측정하지 않았기 때문입니다. 그것이 다음 글의 주제입니다.
이 기사는 저의 개인 오픈 소스 프로젝트인 rag-onpremise의 설계 및 구현을 설명합니다. 측정값은 특정 기관의 배포 사례가 아닌, 저의 테스트 하드웨어와 프로젝트에서 얻은 결과입니다. 여기에 표현된 견해는 저 개인의 의견입니다.
Hubert García Gordon은 코스타리카 공공 부문의 보건 정보 시스템(health information systems) 분야에서 활동하고 있으며, UNED Costa Rica에서 강의하고 있습니다. 그는 rag-onpremise를 관리하며, 제약된 환경(constrained environments)에서의 응용 AI(applied AI)에 대해 글을 씁니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기