Published on

HuggingFace 로컬 LLM RAG 성능·메모리 튜닝

Authors

로컬에서 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 캐시 메모리가 증가(특히 긴 컨텍스트에서)

실전에서는 다음 순서로 줄입니다.

  1. 청크 수를 줄이기(TopK 축소)
  2. 청크 길이를 줄이기(청킹 전략)
  3. 중복 제거 및 요약(압축)
  4. 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) 청킹 전략: 토큰이 아니라 “검색 단위”를 튜닝한다

청킹이 나쁘면 두 가지가 동시에 망가집니다.

  • 검색 품질 하락(정답이 청크 경계에 걸림)
  • 컨텍스트가 불필요하게 커짐(중복 문장 다수)

실전 가이드:

  • 기술 문서: 300600 토큰(또는 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이 나는 경우가 있습니다. 파편화 가능성이 있으니 아래를 점검합니다.

  • 요청마다 텐서 크기가 크게 요동치지 않게(컨텍스트 토큰 예산 강제)
  • 필요 시 프로세스 워커 재시작(예: 일정 요청 수마다 롤링)

실전 체크리스트: “이 순서”로 튜닝하면 빠르다

  1. 토큰 예산 강제: 컨텍스트 길이 상한 + 답변 토큰 상한
  2. TopK 축소 + MMR: 중복 제거로 “적게 가져오기”
  3. rerank로 품질 복구: 후보 20에서 최종 6
  4. LLM 4bit 로딩 + bfloat16 compute: VRAM 확보
  5. 임베딩 모델 디바이스 분리: VRAM 부족이면 임베딩은 CPU
  6. 계측/로그: T_embed, T_retrieve, T_prompt, T_decode를 항상 출력
  7. 장기 안정화: 요약 캐시 + TTL, 워커 롤링, 캐시 상한

마무리: RAG 튜닝의 본질은 “컨텍스트 비용 관리”

HuggingFace 로컬 LLM에 RAG를 붙일 때 성능과 메모리 문제는 대부분 컨텍스트가 커지면서 발생합니다. 그래서 최적화의 핵심도 “검색을 더 많이 하는 것”이 아니라, 필요한 근거를 더 적은 토큰으로 전달하는 쪽에 있습니다.

  • 검색 단계에서는 TopK를 줄이고 중복을 제거
  • 컨텍스트 단계에서는 토큰 예산을 강제하고 요약/압축을 도입
  • 추론 단계에서는 양자화와 디바이스 배치로 VRAM을 확보

이 3가지를 묶어서 반복 측정하면, 같은 하드웨어에서도 체감 성능이 크게 개선됩니다.