Published on

RAG 리콜 급락? HNSW 파라미터 튜닝 가이드

Authors

RAG 시스템에서 “어제까지 잘 나오던 답이 오늘은 못 찾는다”는 현상은 대부분 검색 리콜(recall) 저하로 귀결됩니다. 특히 벡터DB가 HNSW(Hierarchical Navigable Small World) 인덱스를 쓴다면, 리콜은 모델 품질만큼이나 인덱스/검색 파라미터에 민감합니다.

이 글은 “리콜이 급락했다”는 증상을 HNSW 관점에서 재현하고, 원인을 좁힌 뒤, M, efConstruction, efSearch를 중심으로 안전하게 튜닝하는 실전 가이드입니다.

운영 장애 대응 관점에서의 접근법(증상→가설→계측→완화) 흐름은 다른 성격의 성능 이슈에서도 동일합니다. 예를 들어 브라우저 성능에서 Long Task를 추적하듯이, 벡터 검색도 “어디서 손실이 발생했는지”를 계측으로 쪼개야 합니다. 참고: Chrome INP 급등? Long Task 추적·해결 가이드

리콜 급락의 전형적인 증상 패턴

HNSW 기반 벡터 검색에서 리콜이 떨어질 때 관측되는 패턴은 대개 아래 중 하나입니다.

  1. 상위 k 결과가 “관련 없는 문서”로 채워짐
  2. 같은 쿼리인데 결과가 들쭉날쭉(비결정적)해짐
  3. 지연시간을 줄였더니 리콜이 같이 떨어짐
  4. 데이터를 대량 업서트/삭제 후부터 품질이 나빠짐
  5. 인덱스 재빌드/스냅샷 복구 후부터 품질이 달라짐

이때 “임베딩 모델이 나빠졌나?”부터 의심하기 쉬운데, 실제로는 HNSW의 탐색 폭이 너무 좁아져서(혹은 그래프가 빈약해져서) 근접 이웃을 못 찾는 경우가 많습니다.

HNSW가 리콜을 잃는 메커니즘(직관)

HNSW는 벡터들을 그래프로 연결해두고, 쿼리가 들어오면 그래프를 따라가며 근접 이웃을 탐색합니다.

  • 그래프가 촘촘할수록(연결이 많을수록) 좋은 후보를 찾기 쉽습니다.
  • 탐색 시 더 많은 후보를 유지할수록(beam 폭이 넓을수록) 로컬 최적에 빠질 확률이 줄어듭니다.

이 직관이 각각 HNSW 파라미터로 대응됩니다.

  • M: 노드당 최대 연결 수(대략적인 그래프 밀도)
  • efConstruction: 인덱스 구축 시 탐색 폭(그래프 품질)
  • efSearch: 검색 시 탐색 폭(리콜과 지연시간의 핵심 트레이드오프)

즉, 리콜 급락은 보통 다음 중 하나입니다.

  • 그래프 자체 품질이 낮다: M/efConstruction이 낮거나, 구축 과정이 불완전(리소스 부족/중단)했거나, 업데이트 전략이 품질을 깨뜨림
  • 검색 폭이 좁다: efSearch가 낮아져서 후보를 충분히 못 모음

튜닝 전에 먼저 해야 할 “리콜 분해”

리콜 저하는 크게 두 영역으로 나눠야 합니다.

  1. Retrieval 문제: 벡터 검색이 정답 문서를 못 가져옴
  2. Generation 문제: 정답 문서는 가져왔는데 LLM이 답을 못 만듦

HNSW 튜닝은 1번에만 효과가 있습니다. 그래서 먼저 “정답 문서가 top-k에 들어오는지”를 계측해야 합니다.

최소 계측 지표(운영에서 바로 쓰는 것)

  • hit@k: 정답 문서(또는 정답 chunk)가 top-k에 포함되는 비율
  • mrr@k: 정답이 몇 번째에 등장하는지(순위 품질)
  • p95 latency: 검색 지연시간
  • index build time: 인덱스 구축/재빌드 시간
  • memory footprint: 인덱스 메모리 사용량

정답 라벨이 없다면, 다음의 대체 지표를 씁니다.

  • 쿼리-결과 간 cosine 유사도 분포(상위 1~k가 갑자기 낮아졌는지)
  • 쿼리 재실행 시 top-k 결과의 Jaccard 유사도(결과 안정성)

HNSW 파라미터 3종 핵심 정리

efSearch: 리콜 급락의 1순위 원인

  • 의미: 검색 시 유지하는 후보 리스트 크기(탐색 폭)
  • 효과: efSearch가 커질수록 리콜 상승, 대신 CPU 사용량/지연시간 증가
  • 특징: 운영에서 가장 쉽게 바꿔서 즉시 효과를 볼 수 있음

일반적으로 efSearchk보다 충분히 커야 합니다. 경험적으로는 아래처럼 시작합니다.

  • k=10이면 efSearch=50~200
  • 리콜이 민감하면 efSearchk의 10~50배로 올려 A/B 측정

주의: 일부 벡터DB는 efSearch를 너무 크게 올리면 p99 지연이 튀거나, 동시 요청에서 CPU가 포화되어 오히려 타임아웃이 늘 수 있습니다.

M: 그래프 밀도(메모리와 리콜의 장기전)

  • 의미: 각 노드가 유지하는 최대 이웃 수
  • 효과: M이 커질수록 그래프가 촘촘해져 리콜이 좋아질 여지가 큼
  • 비용: 메모리 증가(대체로 선형), 인덱스 구축 시간 증가

M은 인덱스를 다시 만들어야 반영되는 경우가 많아, “급락 대응”보다는 근본 개선에 가깝습니다.

경험적 가이드(데이터/차원/거리함수에 따라 달라짐):

  • 시작점: M=16 또는 M=32
  • 고품질 우선: M=32~64
  • 메모리 제약 강함: M=8~16 (대신 efSearch를 올려 보완)

efConstruction: 인덱스 품질(재빌드 시 체감)

  • 의미: 인덱스 구축 과정에서의 탐색 폭
  • 효과: efConstruction이 커질수록 더 좋은 연결을 찾고, 결과적으로 리콜 상승
  • 비용: 구축 시간 증가(상당히 큼), 빌드 중 CPU 사용량 증가

일반적인 출발점:

  • efConstruction=100~400
  • 리콜이 중요하면 efConstruction을 올려 재빌드 후 efSearch를 낮춰도 품질이 유지되는지 확인

“리콜 급락” 상황에서의 우선순위 플레이북

1) 즉시 완화: efSearch를 올려서 리콜 회복

가장 먼저 할 일은 검색 폭을 넓혀 “그래프 품질이 충분한데 탐색이 좁아서 못 찾는 것인지”를 확인하는 것입니다.

  • efSearch를 2배, 4배로 단계적으로 올림
  • hit@k, mrr@k, p95 latency를 같이 확인

리콜이 즉시 회복되면, 원인은 대개 다음 중 하나입니다.

  • 최근 설정 변경으로 efSearch가 낮아짐
  • 트래픽 증가로 지연 목표를 맞추려 efSearch를 낮춤
  • 동시성 증가로 CPU 병목이 생기며 내부적으로 탐색이 제한됨(타임아웃/조기 종료)

2) 원인 규명: 인덱스 품질 vs 검색 폭

efSearch를 크게 올려도 리콜이 잘 안 오르면, 그래프 자체가 약해졌을 확률이 큽니다.

  • 최근 데이터 대량 변경(업서트/삭제)
  • 인덱스 빌드 중 OOM/중단
  • 스냅샷 복구로 다른 파라미터로 빌드된 인덱스를 가져옴
  • 샤딩/리밸런싱으로 분포가 바뀌며 로컬 그래프가 약해짐

이 경우는 M/efConstruction을 올려 재빌드하거나, 벡터DB가 제공하는 optimize/reindex 류 작업이 필요합니다.

3) 근본 개선: M/efConstruction 재설계

  • 목표 리콜을 먼저 정합니다(예: hit@10 0.95)
  • 그 목표를 만족하는 최소 efSearch를 찾습니다
  • efSearch가 너무 비싸면(p95가 높으면) M/efConstruction을 올려 인덱스 품질을 개선하고, 다시 efSearch를 낮춰봅니다

재현 가능한 튜닝 실험 코드(파이썬)

아래 예시는 hnswlib로 HNSW를 구성해 M, efConstruction, efSearch가 리콜/지연에 미치는 영향을 측정하는 간단한 실험입니다.

주의: 본문에 < > 문자가 노출되면 MDX 빌드 에러가 날 수 있어, 비교 연산자는 코드 블록 안에서만 사용했습니다.

import time
import numpy as np
import hnswlib

def recall_at_k(approx_ids, true_ids):
    # approx_ids: (nq, k)
    # true_ids:   (nq, k)
    nq, k = approx_ids.shape
    hit = 0
    for i in range(nq):
        hit += len(set(approx_ids[i]).intersection(set(true_ids[i])))
    return hit / (nq * k)

rng = np.random.default_rng(42)

dim = 768
n = 200_000
nq = 2_000
k = 10

# 데이터/쿼리
X = rng.normal(size=(n, dim)).astype(np.float32)
Q = rng.normal(size=(nq, dim)).astype(np.float32)

# ground truth (bruteforce)
# 큰 데이터에서는 FAISS exact index를 쓰는 게 보통이지만, 여기선 예시로 단순화
# 연산량이 크니 n을 줄여서 실행하세요.

# HNSW 인덱스 생성
M = 32
efC = 200
index = hnswlib.Index(space='cosine', dim=dim)
index.init_index(max_elements=n, ef_construction=efC, M=M)
index.add_items(X, np.arange(n))

# 검색 파라미터 스윕
for efS in [20, 50, 100, 200, 400]:
    index.set_ef(efS)
    t0 = time.perf_counter()
    labels, distances = index.knn_query(Q, k=k)
    dt = time.perf_counter() - t0

    # 여기서는 true_ids 없이 지연만 보는 형태로 둠
    # 실제 튜닝에서는 exact top-k를 구해 recall@k, mrr@k를 계산하세요.

    print({
        'M': M,
        'efConstruction': efC,
        'efSearch': efS,
        'qps': round(nq / dt, 2),
        'avg_ms': round((dt / nq) * 1000, 3)
    })

실무에서는 위 코드에서 다음을 추가합니다.

  • exact top-k 계산(FAISS IndexFlatIP 등)로 hit@k, mrr@k 산출
  • 쿼리 샘플을 “최근 실패 케이스” 위주로 구성
  • 쿼리 길이/도메인별로 그룹핑해 리콜 저하가 특정 세그먼트에만 발생하는지 확인

운영 튜닝 시 흔한 함정 7가지

1) k만 올리고 끝내기

k를 키우면 LLM에 더 많은 문서를 주입할 수는 있지만, 검색이 이미 틀린 방향이면 노이즈만 늘어 답이 더 나빠지기도 합니다. 먼저 hit@k가 회복되는지 확인하세요.

2) efSearch를 올렸는데 지연이 튀는 이유

  • CPU 포화로 인한 큐잉
  • 동시성 증가로 캐시 미스/컨텍스트 스위칭 증가
  • 벡터DB 내부 타임아웃으로 탐색이 조기 종료

이 경우는 단순 파라미터 문제가 아니라 리소스/동시성 문제일 수 있습니다. DB 커넥션 풀 고갈을 진단하듯이 “병목이 어디서 생겼는지”부터 확인해야 합니다. 참고: Spring Boot HikariCP 커넥션 고갈 원인과 해결

3) 대량 업서트 후 품질 저하

벡터DB에 따라 HNSW는 “동적 업데이트”에서 품질이 서서히 나빠질 수 있습니다.

  • 해결: 주기적 재빌드/컴팩션/옵티마이즈 작업
  • 예방: 배치 업서트 후 검증 쿼리로 hit@k 스모크 테스트

4) 거리함수/정규화 불일치

cosine을 쓰는데 벡터 정규화를 안 했거나, inner product로 바꿨는데 스케일이 달라진 경우, 리콜이 아니라 “유사도 의미”가 바뀐 것입니다.

  • 동일 모델이라도 전처리(정규화, pooling)가 바뀌면 결과가 크게 달라집니다.

5) 임베딩 차원/모델 변경 후 인덱스 재사용

차원이 바뀌었는데 인덱스를 그대로 쓰는 실수는 보통 즉시 에러가 나지만, 일부 파이프라인에서는 “이상한 값”으로 조용히 망가질 수 있습니다. 모델 버전과 인덱스 버전을 강하게 묶으세요.

6) 샤딩 후 리콜 저하

샤드별 top-k를 합치는 방식은 전역 top-k를 보장하지 않습니다.

  • 샤드당 k를 더 크게 뽑고 merge 단계에서 top-k를 재선정
  • 또는 라우팅/프루닝 전략을 개선

7) “리콜”이 아니라 “정답 chunk 설계” 문제

문서 chunk가 너무 크거나 작으면, 검색이 맞아도 답변이 빈약해질 수 있습니다.

  • chunk 크기/overlap, 메타데이터 필터, reranker 도입 여부를 함께 점검하세요.

추천 튜닝 레시피(현업에서 자주 쓰는 값)

아래는 “대부분의 일반적인 텍스트 임베딩 RAG”에서 출발점으로 삼기 좋은 조합입니다.

  • 초기(밸런스): M=16, efConstruction=200, efSearch=100
  • 리콜 우선: M=32, efConstruction=400, efSearch=200~400
  • 비용 우선(저지연): M=16, efConstruction=200, efSearch=50~100 + 필요 시 reranker로 보정

중요한 건 절대값보다 측정 루프입니다.

  1. 목표 hit@k 설정
  2. efSearch 스윕으로 목표 달성 최소값 찾기
  3. 지연/비용이 과하면 M/efConstruction 올려 재빌드
  4. 다시 efSearch를 낮춰 비용 절감

체크리스트: “리콜 급락” 장애 대응용

  • 동일 쿼리로 exact(또는 고정밀) 기준과 비교해 hit@k가 떨어졌는가
  • 최근에 efSearch 또는 성능 관련 설정이 바뀌었는가
  • CPU 사용률/로드애버리지가 상승했는가(탐색 조기 종료 가능)
  • 대량 업서트/삭제/샤딩/리밸런싱이 있었는가
  • 인덱스 재빌드/복구 시 M/efConstruction 값이 달라졌는가
  • 거리함수/정규화/임베딩 모델 버전이 바뀌었는가
  • chunk 전략/메타데이터 필터가 변경되었는가

마무리

HNSW에서 리콜 급락은 감으로 해결하기 어렵고, 반대로 측정 루프만 만들면 가장 빨리 안정화할 수 있는 문제이기도 합니다.

  • 단기 처방은 efSearch 조정
  • 장기 처방은 M/efConstruction 재설계와 재빌드 전략
  • 그리고 무엇보다 hit@k/mrr@k 같은 지표를 파이프라인에 상시로 붙여 “언제부터 나빠졌는지”를 즉시 알 수 있게 만드는 것이 핵심입니다.

운영 중 설정 변경이나 트래픽 변화로 품질이 흔들린다면, 위 플레이북대로 efSearch부터 올려 빠르게 복구한 뒤, 재빌드 가능한 윈도우에서 M/efConstruction을 조정해 비용 대비 리콜을 최적화해 보세요.