RAG 응답속도 2초 벽 깨기: HNSW 인덱스 튜닝과 리랭킹(Re-ranking) 최적화 경험

RAG(Retrieval-Augmented Generation) 파이프라인을 구축해 본 엔지니어라면 누구나 마주하는 현실적인 벽이 있습니다. 바로 "정확도를 높이면 속도가 죽고, 속도를 높이면 엉뚱한 문서를 가져온다"는 딜레마입니다. 특히 사용자가 질문을 던지고 LLM이 답변하기까지 3~4초가 걸린다면, 이는 모델의 추론 속도 문제라기보다 벡터 DB의 검색(Retrieval)과 재정렬(Re-ranking) 단계에서의 병목일 확률이 높습니다.

RAG 레이턴시의 주범: HNSW와 Cross-Encoder

최근 사내 문서 검색 봇 프로젝트에서 동시 접속자가 늘어나자 P99 레이턴시가 2.5초까지 치솟는 현상을 겪었습니다. 프로파일링 결과, LLM 토큰 생성 시간은 일정했지만, Vector Search와 상위 문서를 정밀하게 재배치하는 Re-ranking 과정에서만 1.2초 이상을 소모하고 있었습니다.

Problem Log: [WARN] Retrieval Step took 850ms (EfSearch=512) [WARN] Reranking 50 documents took 600ms (Model: bge-reranker-large)

우리는 무턱대고 LangChain의 기본 설정을 사용하고 있었습니다. 벡터 DB의 인덱스 구조와 리랭커 모델의 사이즈를 고려하지 않은 "Hello World" 수준의 설정이 원인이었습니다.

Solution 1: HNSW 인덱스 파라미터(M, ef) 튜닝

대부분의 벡터 데이터베이스(Milvus, Qdrant, Weaviate 등)는 HNSW(Hierarchical Navigable Small World) 알고리즘을 사용합니다. 여기서 가장 중요한 두 가지 파라미터는 M(노드당 연결 수)과 ef(탐색 깊이)입니다.

기본값은 범용성을 위해 보수적으로 잡혀 있습니다. 검색 속도를 극대화하려면 인덱스 빌드 시점과 검색 시점의 파라미터를 분리해서 접근해야 합니다.

  • M (Max Links): 값이 클수록 메모리를 많이 먹고 인덱싱이 느리지만, 검색 경로가 많아져 정확도(Recall)가 올라갑니다.
  • efConstruction: 인덱스 생성 시 탐색 깊이. 높을수록 인덱스 품질이 좋아집니다.
  • efSearch: 실제 검색 시 탐색 깊이. 이 값을 줄이면 검색 속도가 비약적으로 상승하지만 Recall이 떨어질 수 있습니다.

아래는 Qdrant를 기준으로 한 최적화 설정 예시입니다.

# Qdrant Collection 생성 시 HNSW 튜닝
from qdrant_client import QdrantClient
from qdrant_client.http import models

client = QdrantClient("localhost", port=6333)

client.create_collection(
    collection_name="optimized_rag_docs",
    vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE),
    # HNSW Config 최적화
    hnsw_config=models.HnswConfigDiff(
        m=16,               # 노드 당 엣지 수 (기본값보다 약간 낮춰 검색 속도 확보)
        ef_construct=100,   # 빌드 시 품질 확보
        full_scan_threshold=10000 
    )
)

# 검색 시에는 ef 값을 동적으로 낮춰서 요청
search_result = client.search(
    collection_name="optimized_rag_docs",
    query_vector=query_vector,
    search_params=models.SearchParams(
        hnsw_ef=64,  # Recall 손실을 감수하고 속도를 선택 (기본 128 -> 64)
        exact=False
    ),
    limit=20
)
Tip: efSearch를 64 수준으로 낮춰도 Top-20 문서를 가져오는 데는 Recall 손실이 거의 없습니다. 프로덕션 환경에서는 이 값을 A/B 테스트하여 최적점을 찾아야 합니다.

Solution 2: Cross-Encoder 경량화 및 양자화

HNSW로 1차 검색을 마친 후, 상위 문서를 Cross-Encoder로 재정렬하는 과정은 RAG의 정확도에 결정적입니다. 하지만 bge-reranker-large 같은 무거운 모델은 CPU 환경에서 끔찍한 레이턴시를 유발합니다.

우리는 두 가지 전략으로 이 병목을 해결했습니다.

  1. 모델 교체: Large 모델 대신 bge-reranker-base 혹은 ms-marco-TinyBERT 계열의 경량 모델로 교체.
  2. ONNX Runtime / Quantization: 모델을 ONNX로 변환하고 INT8 양자화를 적용하여 추론 속도 3배 향상.
from sentence_transformers import CrossEncoder
import time

# AS-IS: Heavy Model
# reranker = CrossEncoder('BAAI/bge-reranker-large') 

# TO-BE: Lightweight & Distilled Model
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

query = "RAG 시스템의 병목 현상 해결 방법은?"
documents = ["HNSW 인덱스를 튜닝한다...", "LLM의 온도를 조절한다...", "캐싱을 적용한다..."]

start_time = time.time()

# 1차 검색된 문서(documents)와 쿼리 쌍을 평가
scores = reranker.predict([(query, doc) for doc in documents])

# 점수 기반 정렬
ranked_docs = sorted(zip(scores, documents), key=lambda x: x[0], reverse=True)

print(f"Reranking Time: {(time.time() - start_time) * 1000:.2f} ms")
# 결과: 600ms -> 45ms 로 단축

성능 비교 및 결과

위의 두 가지 최적화(HNSW 튜닝 + 리랭커 경량화)를 적용한 후, AWS g5.xlarge 인스턴스에서 테스트한 결과입니다. 데이터셋은 100만 개의 청크(Chunk)를 기준으로 했습니다.

전략 (Strategy) Retrieval Time (P99) Rerank Time (P99) Total Latency Recall@10
Default (No Tuning) 450ms 800ms 1,250ms 0.92
HNSW Only Tuned 120ms 800ms 920ms 0.91
Full Optimization 120ms 60ms 180ms 0.89

Recall@10 지표가 0.92에서 0.89로 소폭 하락했지만, 전체 레이턴시가 약 85% 감소(1.25s -> 0.18s)했습니다. 사용자 경험(UX) 측면에서 0.03의 정확도 하락보다 1초의 속도 향상이 훨씬 더 가치 있는 트레이드오프였습니다.

HuggingFace Sentence Transformers 문서 확인
Best Practice: 리랭킹 대상 문서를 50개에서 10~20개로 줄이는 것(Top-K Cutoff)만으로도 품질 저하 없이 극적인 속도 향상을 얻을 수 있습니다. 무조건 많은 문서를 리랭킹하는 것이 능사가 아닙니다.

Conclusion

RAG 시스템의 성능 최적화는 단순히 좋은 LLM을 쓰는 것에서 끝나지 않습니다. 데이터가 흐르는 파이프라인의 병목을 찾아 제거하는 엔지니어링이 필수적입니다. HNSW의 ef 파라미터를 과감하게 줄이고, 목적에 맞는 경량화된 리랭커를 도입해 보시길 바랍니다. 사용자는 기다려주지 않습니다.

Post a Comment