- Published on
HuggingFace 로컬 LLM RAG 성능·메모리 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
로컬에서 HuggingFace 기반 LLM에 RAG를 붙이면, “모델만 잘 올리면 끝”이 아니라 임베딩 생성, 벡터 검색, 컨텍스트 구성, 추론 네 구간이 서로 다른 방식으로 메모리와 지연시간을 잡아먹습니다. 특히 GPU VRAM이 빡빡한 환경에서는 RAG가 오히려 컨텍스트 길이 증가로 인해 토큰당 지연이 커지고, CPU RAM에서는 문서 청킹·인덱싱·캐시가 누적되며 장기 실행 시 안정성이 떨어집니다.
이 글은 다음을 목표로 합니다.
- 병목을 구간별로 계측해 “느린 지점”을 확정하기
- VRAM 부족, OOM, 스왑, 응답 지연을 줄이는 실전 옵션 조합
- RAG 품질을 유지하면서 컨텍스트·검색 비용을 낮추는 전략
관련해서 장기 실행 시 메모리 폭주를 다루는 글도 함께 보면 좋습니다: AutoGPT 메모리 폭주 해결 - 벡터DB+요약 TTL
전체 파이프라인을 4구간으로 쪼개서 본다
로컬 RAG의 지연시간은 대략 아래 합으로 볼 수 있습니다.
T_embed: 쿼리 임베딩 생성T_retrieve: 벡터 검색 및 후처리(MMR, rerank 등)T_prompt: 컨텍스트 구성(문서 정렬, 중복 제거, 토큰 예산 맞추기)T_decode: LLM 프리필(prefill) + 디코딩(decode)
메모리도 마찬가지로 분리됩니다.
- GPU VRAM: LLM 가중치 + KV 캐시 + (옵션) 임베딩 모델
- CPU RAM: 원문 텍스트, 청크, 인덱스, 캐시, reranker 모델
핵심은 “RAG가 느리다”가 아니라, 어느 구간이 느린지를 먼저 확정하는 것입니다.
간단한 계측 래퍼
아래는 각 구간 시간을 찍어 병목을 확인하는 최소 코드입니다.
import time
from contextlib import contextmanager
@contextmanager
def timer(name: str):
t0 = time.perf_counter()
yield
t1 = time.perf_counter()
print(f"{name}: {(t1 - t0)*1000:.1f} ms")
로컬 LLM 추론 튜닝: VRAM과 지연의 80%를 결정한다
RAG에서 LLM 구간(T_decode)은 보통 가장 비싸고, 다음이 컨텍스트 길이에 따라 폭증하는 프리필입니다. 즉 컨텍스트 토큰을 줄이는 것과 KV 캐시 효율이 곧 성능입니다.
1) 4bit/8bit 로딩과 device_map 전략
- 단일 GPU VRAM이 작으면 4bit 양자화가 사실상 필수
- CPU 오프로딩은 OOM을 막지만, PCIe 전송 때문에 지연이 커질 수 있음
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_id = "meta-llama/Llama-3.1-8B-Instruct" # 예시
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
tok = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb,
device_map="auto",
torch_dtype=torch.bfloat16,
)
model.eval()
튜닝 포인트:
bnb_4bit_compute_dtype는 가능한bfloat16(GPU 지원 시)로device_map="auto"는 편하지만, 특정 레이어가 CPU로 밀리면 지연이 급증할 수 있어accelerate의 균형 배치를 고려
양자화/가속 전반은 아래 글도 참고할 만합니다: PyTorch 8bit PTQ - ONNX+TensorRT로 2배 가속
2) 컨텍스트 길이와 KV 캐시: “RAG가 느려지는” 주범
RAG는 검색 문서를 붙이면서 입력 토큰이 늘어납니다. 입력 토큰이 늘면:
- 프리필 비용이 선형으로 증가
- KV 캐시 메모리가 증가(특히 긴 컨텍스트에서)
실전에서는 다음 순서로 줄입니다.
- 청크 수를 줄이기(TopK 축소)
- 청크 길이를 줄이기(청킹 전략)
- 중복 제거 및 요약(압축)
- rerank로 “적은 청크로 품질 유지”
3) 생성 파라미터로 디코딩 비용 줄이기
max_new_tokens를 제한- 스트리밍으로 체감 지연 감소
- 필요 시
temperature를 낮춰 불필요한 장문 생성을 줄임
from transformers import TextStreamer
streamer = TextStreamer(tok, skip_prompt=True, skip_special_tokens=True)
inputs = tok("질문: ...\n답변:", return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(
**inputs,
max_new_tokens=256,
do_sample=False,
temperature=0.0,
streamer=streamer,
)
임베딩/검색 튜닝: CPU RAM과 지연을 안정화한다
로컬 RAG에서 임베딩과 검색은 “한 번 구축하면 끝”처럼 보이지만, 실제로는 다음에서 문제가 생깁니다.
- 임베딩 모델이 GPU를 같이 쓰며 VRAM을 잠식
- 인덱스가 커지며 RAM을 지속적으로 점유
- 문서가 늘수록 검색 후처리(MMR, rerank)가 병목
1) 임베딩 모델을 GPU에 올릴지 CPU에 둘지
원칙:
- VRAM이 넉넉하면 임베딩도 GPU로(지연 감소)
- VRAM이 빡빡하면 임베딩은 CPU로, LLM에 VRAM을 몰아주기
sentence-transformers 예시:
from sentence_transformers import SentenceTransformer
embed_model_id = "intfloat/multilingual-e5-large" # 예시
embedder = SentenceTransformer(embed_model_id, device="cpu")
def embed(texts):
# normalize_embeddings는 cosine 유사도에서 유리
return embedder.encode(texts, normalize_embeddings=True, batch_size=32)
튜닝 포인트:
batch_size를 올리면 빠르지만 RAM 사용량 증가normalize_embeddings=True로 dot product 기반 검색 단순화
2) 청킹 전략: 토큰이 아니라 “검색 단위”를 튜닝한다
청킹이 나쁘면 두 가지가 동시에 망가집니다.
- 검색 품질 하락(정답이 청크 경계에 걸림)
- 컨텍스트가 불필요하게 커짐(중복 문장 다수)
실전 가이드:
- 기술 문서: 300
600 토큰(또는 1,0002,000자) + 10~20% 오버랩 - FAQ/짧은 문서: 오버랩 최소화, 섹션 단위로 자르기
- 코드/로그: 함수/스택트레이스 경계 유지
3) TopK와 MMR: “많이 가져오기”는 거의 항상 손해
- TopK를 20, 30 가져오면 검색은 좋아 보이지만 LLM 프리필이 폭증
- 보통은 TopK를 4~8로 두고, 필요 시 rerank로 보정
MMR(Maximal Marginal Relevance)로 중복을 줄이는 예시(개념 코드):
import numpy as np
def mmr(query_vec, doc_vecs, k=6, lambda_=0.7):
selected = []
candidates = list(range(len(doc_vecs)))
sim_q = doc_vecs @ query_vec
sim_d = doc_vecs @ doc_vecs.T
for _ in range(k):
if not candidates:
break
scores = []
for c in candidates:
diversity = 0.0
if selected:
diversity = max(sim_d[c, s] for s in selected)
score = lambda_ * sim_q[c] - (1 - lambda_) * diversity
scores.append((score, c))
_, best = max(scores)
selected.append(best)
candidates.remove(best)
return selected
튜닝 포인트:
k를 줄이고lambda_로 중복 억제 강도 조절- MMR은 계산량이 늘 수 있으니, 후보군을 30에서 10으로 줄이는 식으로 절충
컨텍스트 구성 튜닝: “토큰 예산”을 운영하는 방식
RAG 품질/성능을 동시에 잡으려면 컨텍스트를 무작정 붙이지 말고, 토큰 예산을 강제해야 합니다.
1) 토큰 예산 기반 컨텍스트 빌더
- 시스템 프롬프트 + 질문 + 컨텍스트 + 출력 토큰을 합쳐 모델 최대 길이를 넘기지 않게
- 컨텍스트는 “점수 높은 것부터” 넣되, 길이 제한에 걸리면 요약/절단
from transformers import AutoTokenizer
def build_context(tokenizer, query, chunks, max_input_tokens=4096, reserve_for_answer=512):
budget = max_input_tokens - reserve_for_answer
header = "당신은 문서 기반으로만 답합니다. 근거를 문서 조각으로 인용하세요.\n"
base = f"{header}\n질문: {query}\n\n참고 문서:\n"
ids = tokenizer(base, add_special_tokens=False).input_ids
used = len(ids)
picked = []
for c in chunks:
block = f"- 출처: {c['source']}\n 내용: {c['text']}\n"
block_ids = tokenizer(block, add_special_tokens=False).input_ids
if used + len(block_ids) > budget:
break
picked.append(block)
used += len(block_ids)
prompt = base + "\n".join(picked) + "\n\n답변:"
return prompt
튜닝 포인트:
reserve_for_answer를 작게 잡으면 답변이 잘리거나 품질이 흔들림max_input_tokens는 모델/서빙 설정에 맞게
2) “요약 캐시”와 TTL로 장기 실행 메모리 안정화
문서가 크거나 대화가 길어질수록 컨텍스트 압축이 필요합니다.
- 자주 참조되는 문서 청크는 요약본을 별도 저장
- 요약본에도 TTL을 둬서 오래된 요약이 쌓이지 않게
이 패턴은 에이전트/장기 메모리에서도 동일하게 유효합니다: AutoGPT 메모리 폭주 해결 - 벡터DB+요약 TTL
Rerank 도입: 적은 컨텍스트로도 정확도를 유지하는 핵심 카드
TopK를 줄이면 품질이 떨어질 수 있는데, 이를 보완하는 대표적인 방법이 rerank입니다.
- 1차: 임베딩 검색으로 후보 20개
- 2차: cross-encoder rerank로 상위 4~6개만 LLM에 투입
단점은 reranker가 CPU/GPU 자원을 추가로 먹는다는 점입니다. 하지만 LLM 프리필 비용이 더 큰 경우가 많아, 결과적으로 전체 지연이 줄어들기도 합니다.
간단한 구조 예시:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device="cpu")
def rerank(query, chunks, top_n=6):
pairs = [(query, c["text"]) for c in chunks]
scores = reranker.predict(pairs)
ranked = sorted(zip(scores, chunks), key=lambda x: x[0], reverse=True)
return [c for _, c in ranked[:top_n]]
튜닝 포인트:
- reranker는 CPU로도 충분한 경우가 많지만, QPS가 높으면 병목이 됨
- 후보군 크기(예: 50)를 무작정 키우지 말고 10~30에서 최적점 찾기
서빙 레벨 튜닝: 동시성, 캐시, 메모리 파편화
로컬 RAG는 “한 번 응답”보다 “오래 돌렸을 때” 문제가 자주 터집니다.
1) 동시성 제한과 큐잉
GPU 1장 환경에서 동시 요청을 무리하게 받으면:
- KV 캐시가 겹치며 VRAM 급증
- 컨텍스트가 긴 요청이 들어오면 다른 요청까지 지연
실전에서는 다음 중 하나를 택합니다.
- 동시 추론
1로 고정하고 큐로 받기 - 짧은 요청과 긴 요청을 분리 라우팅
2) 캐시 포인트를 정확히 잡기
캐시는 아무 데나 넣으면 메모리만 먹습니다. 효과가 큰 지점은 보통 두 군데입니다.
- 쿼리 임베딩 캐시(동일 질문 반복)
- 검색 결과 캐시(동일 질문 또는 유사 질문)
반면, LLM 출력 캐시는 프롬프트가 조금만 바뀌어도 적중률이 낮아질 수 있습니다.
3) 파이토치 메모리 파편화 대응
장기 실행에서 VRAM이 남아 있는데도 OOM이 나는 경우가 있습니다. 파편화 가능성이 있으니 아래를 점검합니다.
- 요청마다 텐서 크기가 크게 요동치지 않게(컨텍스트 토큰 예산 강제)
- 필요 시 프로세스 워커 재시작(예: 일정 요청 수마다 롤링)
실전 체크리스트: “이 순서”로 튜닝하면 빠르다
- 토큰 예산 강제: 컨텍스트 길이 상한 + 답변 토큰 상한
- TopK 축소 + MMR: 중복 제거로 “적게 가져오기”
- rerank로 품질 복구: 후보 20에서 최종 6
- LLM 4bit 로딩 + bfloat16 compute: VRAM 확보
- 임베딩 모델 디바이스 분리: VRAM 부족이면 임베딩은 CPU
- 계측/로그:
T_embed,T_retrieve,T_prompt,T_decode를 항상 출력 - 장기 안정화: 요약 캐시 + TTL, 워커 롤링, 캐시 상한
마무리: RAG 튜닝의 본질은 “컨텍스트 비용 관리”
HuggingFace 로컬 LLM에 RAG를 붙일 때 성능과 메모리 문제는 대부분 컨텍스트가 커지면서 발생합니다. 그래서 최적화의 핵심도 “검색을 더 많이 하는 것”이 아니라, 필요한 근거를 더 적은 토큰으로 전달하는 쪽에 있습니다.
- 검색 단계에서는 TopK를 줄이고 중복을 제거
- 컨텍스트 단계에서는 토큰 예산을 강제하고 요약/압축을 도입
- 추론 단계에서는 양자화와 디바이스 배치로 VRAM을 확보
이 3가지를 묶어서 반복 측정하면, 같은 하드웨어에서도 체감 성능이 크게 개선됩니다.