Published on

RAG 정확도 폭락? Milvus HNSW 튜닝 7가지

Authors

RAG 시스템을 운영하다 보면 어느 날부터 답변 품질이 눈에 띄게 나빠지는 순간이 옵니다. 로그를 보면 LLM은 멀쩡한데, 검색 결과가 엉뚱하거나 근거 문서가 비어 있는 경우가 많습니다. 이런 상황은 대개 벡터 검색 recall 하락으로 설명되고, Milvus에서 HNSW를 쓴다면 원인은 생각보다 자주 튜닝 파라미터와 운영 조건의 조합에서 발생합니다.

이 글은 “RAG 정확도 폭락”을 HNSW 관점에서 재현 가능하게 진단하고, recall과 latency를 함께 회복시키는 7가지 튜닝 포인트를 정리합니다. 이미 HNSW 자체 설명을 알고 있다면, 아래 체크리스트만 따라가도 장애 대응 속도가 빨라집니다.

참고로 HNSW의 기본 개념과 파라미터별 trade-off를 더 깊게 보고 싶다면 이 글도 함께 보세요.

0. 먼저 확인할 증상 분류: “정확도 폭락”의 3가지 타입

정확도가 떨어졌다고 해서 모두 HNSW 문제는 아닙니다. 하지만 HNSW 튜닝으로 해결되는 케이스는 대체로 아래 3가지로 나타납니다.

  1. TopK 결과가 유사하지 않다: 쿼리와 전혀 관련 없는 문서가 섞임
  2. 결과가 불안정하다: 같은 쿼리인데 호출마다 결과가 달라짐
  3. 결과가 비거나 극단적으로 적다: 특히 필터를 걸었을 때 자주 발생

이 중 1, 2는 주로 recall 부족 또는 거리 metric 불일치, 3은 필터와 검색 파라미터 조합 또는 세그먼트/인덱스 상태 문제로 이어지는 경우가 많습니다.

이제부터는 HNSW를 중심으로, 운영에서 실제로 recall을 무너뜨리는 포인트 7가지를 다룹니다.


1) metric_type 불일치: COSINE과 IP, L2 혼용 사고

RAG 정확도 폭락의 가장 흔한 원인 중 하나가 임베딩 모델의 성질과 metric이 안 맞는 것입니다.

  • 임베딩이 정규화되어 있고 각도 기반 유사도를 기대한다면 보통 COSINE
  • 정규화된 벡터에서 내적 기반을 쓰면 IP도 가능하지만, 구현/설정에 따라 기대값이 달라질 수 있음
  • 정규화가 안 된 상태에서 L2를 쓰면 벡터 크기(노름)가 결과를 지배할 수 있음

특히 모델을 교체했는데 Milvus 컬렉션의 metric을 그대로 두면, “아무리 튜닝해도” 정확도가 안 돌아옵니다.

점검 체크

  • 인덱스 생성 시 metric_type이 무엇인지
  • 검색 시 metric_type을 오버라이드하고 있지 않은지
  • 임베딩 파이프라인에서 정규화 여부가 바뀌지 않았는지

예시: 인덱스 생성 시 metric 명시

from pymilvus import Collection

collection = Collection("docs")
index_params = {
  "index_type": "HNSW",
  "metric_type": "COSINE",
  "params": {"M": 32, "efConstruction": 200}
}
collection.create_index(field_name="embedding", index_params=index_params)

2) ef가 너무 낮다: recall을 깎아먹는 1순위

HNSW에서 검색 품질은 ef에 크게 좌우됩니다. ef는 검색 시 탐색 폭을 의미하고, 낮으면 latency는 줄지만 recall이 급격히 떨어질 수 있습니다.

운영 중 “갑자기” 정확도가 떨어지는 경우는, 다음 같은 변경이 숨어 있을 때가 많습니다.

  • 트래픽 증가로 인해 ef를 낮춰 latency를 맞추는 임시 조치
  • 앱 레벨에서 검색 파라미터 기본값이 바뀜
  • 멀티 테넌트 환경에서 특정 워크로드가 ef를 과도하게 낮춤

권장 접근

  • 먼저 topk를 고정하고 ef만 올려 recall 변화 관찰
  • ef는 보통 topk보다 충분히 커야 합니다. 최소한 eftopk보다 작으면 결과가 불안정해질 가능성이 큽니다.

예시: 검색 시 ef 조정

search_params = {
  "metric_type": "COSINE",
  "params": {"ef": 128}
}

results = collection.search(
  data=[query_vec],
  anns_field="embedding",
  param=search_params,
  limit=10,
  output_fields=["doc_id", "title"]
)

3) MefConstruction이 낮은 인덱스를 “그대로” 운영한다

MefConstruction은 인덱스 빌드 품질을 좌우합니다.

  • M: 노드당 연결 수. 높을수록 그래프가 풍부해져 recall이 좋아지지만 메모리 증가
  • efConstruction: 인덱스 구축 시 탐색 폭. 높을수록 구축 시간 증가, recall 개선

문제는, 초기 PoC에서 빠르게 만들려고 M=8, efConstruction=64 같은 값으로 인덱스를 만들고, 데이터가 커졌는데도 그대로 운영하는 경우입니다. 이때 검색 ef만 올려도 한계가 있습니다. 인덱스 자체가 빈약하면 탐색할 길이 적기 때문입니다.

실전 기준(경험적)

  • RAG 문서 검색에서 M16 또는 32부터 시작하는 경우가 많습니다.
  • efConstruction200 전후로 시작해, 데이터 크기와 메모리 여유에 따라 올립니다.

예시: 인덱스 재생성

collection.drop_index()

index_params = {
  "index_type": "HNSW",
  "metric_type": "COSINE",
  "params": {"M": 32, "efConstruction": 256}
}
collection.create_index("embedding", index_params)

인덱스를 바꿨는데도 정확도가 회복되지 않으면, 다음 항목인 데이터 분포 문제로 넘어가야 합니다.


4) 세그먼트와 인덱스 상태: “일부만 인덱스”인 채로 검색한다

Milvus는 데이터가 세그먼트 단위로 관리되고, 상황에 따라 일부 세그먼트는 아직 인덱스가 없거나 로드 상태가 다를 수 있습니다. 이때 검색은 섞인 전략으로 수행되어 recall이 흔들릴 수 있습니다.

정확도 폭락이 특정 시점(대량 적재 직후, 배치 업서트 직후)에 발생했다면 특히 의심해야 합니다.

점검 포인트

  • 대량 insert 후 인덱스 빌드가 끝났는지
  • 컬렉션이 메모리에 제대로 로드되어 있는지
  • 검색이 인덱스 기반인지, brute force가 섞였는지

예시: 로드 보장

collection.load()

운영에서는 배치 적재 후에 아래 흐름을 명확히 두는 것이 안전합니다.

  • insert
  • flush
  • index build 또는 index refresh
  • load
  • 서비스 트래픽 오픈

5) 필터와 HNSW의 조합: 결과가 비거나 엉키는 이유

RAG에서 메타데이터 필터(tenant, time range, doc_type)를 자주 씁니다. 그런데 필터가 강해질수록 후보 풀이 급격히 줄어들어, HNSW 탐색이 충분히 확장되지 못하면 TopK를 채우지 못하거나 관련 없는 결과로 채워질 수 있습니다.

이때의 해법은 단순히 topk를 줄이는 게 아니라, 보통 다음 중 하나입니다.

  • 필터를 먼저 적용한 별도 컬렉션/파티션 설계
  • 필터가 강한 쿼리에서는 ef를 더 올리는 정책
  • 하이브리드 전략: 필터 후보를 좁힌 뒤 벡터 검색 또는 재랭킹

예시: 필터와 함께 검색

아래 예시에서 문자열 비교 연산자는 사용 환경에 맞게 조정해야 합니다.

search_params = {"metric_type": "COSINE", "params": {"ef": 256}}

results = collection.search(
  data=[query_vec],
  anns_field="embedding",
  param=search_params,
  limit=10,
  expr="tenant_id == 42 and doc_type in [\"guide\", \"api\"]"
)

필터가 걸린 쿼리에서만 정확도가 떨어진다면, ef를 쿼리 타입별로 다르게 주는 것만으로도 급격히 개선되는 경우가 많습니다.


6) 데이터 분포 변화: 임베딩 드리프트와 “근접 이웃 밀집”

정확도가 갑자기 떨어졌는데 파라미터를 안 바꿨다면, 데이터가 바뀐 것입니다.

  • 문서 도메인이 바뀌거나(예: 기술 문서에서 CS 문의로)
  • chunking 전략이 바뀌거나(길이 증가, 중복 증가)
  • 임베딩 모델 버전이 바뀌거나
  • 동일/유사 문서가 대량 유입되어 특정 영역에 벡터가 밀집되거나

이런 상황에서는 HNSW가 “틀렸다”기보다, 탐색 난이도가 올라가 recall이 떨어지는 것처럼 보입니다.

대응 전략

  • 중복 chunk 제거(해시 기반 near-duplicate 제거)
  • chunk 크기와 overlap 재조정
  • 재랭킹 추가(특히 상위 50 정도 뽑고 cross-encoder로 재정렬)
  • 인덱스 파라미터 상향: M, efConstruction, 검색 ef 동반 상향

간단한 중복 제거 예시(문자열 해시)

import hashlib

def chunk_id(text: str) -> str:
    return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()

seen = set()
unique_chunks = []
for c in chunks:
    cid = chunk_id(c)
    if cid in seen:
        continue
    seen.add(cid)
    unique_chunks.append(c)

7) 튜닝은 “고정값”이 아니라 “정책”이다: 워크로드별 ef 자동화

RAG는 쿼리 난이도가 제각각입니다.

  • 짧고 모호한 질의는 더 넓게 탐색해야 하고
  • 필터가 강한 질의도 탐색 폭이 필요하며
  • 트래픽이 몰리는 시간대는 latency 예산이 빡빡해집니다

그래서 ef=128 같은 고정값은 장기적으로 깨지기 쉽습니다. 실전에서는 워크로드에 따라 ef를 다르게 주는 정책이 효과적입니다.

정책 예시

  • 기본: topk=10이면 ef=128
  • 필터가 강함: ef=256
  • 재랭킹 사용: 1차 검색 topk=50, ef=256, 재랭킹 후 10개로 축소
  • 트래픽 폭증: ef 하향하되, 품질 저하 감지 시 자동 롤백

예시: 간단한 ef 선택 함수

def choose_ef(topk: int, has_filter: bool, rerank: bool) -> int:
    base = max(64, topk * 8)
    if has_filter:
        base *= 2
    if rerank:
        base = max(base, 256)
    return min(base, 1024)

운영에서 중요한 건 “한 번 튜닝하고 끝”이 아니라, 품질 지표를 보고 자동으로 조절하는 루프를 만드는 것입니다.


운영 체크리스트: 정확도 폭락 시 15분 안에 보는 순서

  1. metric 불일치 여부 확인: COSINE인지, 정규화가 바뀌었는지
  2. 검색 파라미터에서 ef가 낮아졌는지 확인
  3. 인덱스 파라미터 확인: M, efConstruction이 PoC 값에 머물러 있지 않은지
  4. 세그먼트/로드/인덱스 상태 확인: 대량 적재 직후인지
  5. 필터 쿼리에서만 문제인지 분리
  6. 데이터 분포 변화 확인: chunking, 중복, 모델 버전
  7. 고정 튜닝이 아닌 정책화: 워크로드별 ef 차등 적용

마무리: HNSW 튜닝의 목표는 “recall 안정화”

RAG에서 체감 정확도는 대개 LLM보다 검색 recall의 안정성에 더 크게 좌우됩니다. Milvus HNSW는 강력하지만, ef와 인덱스 품질, 필터 조합, 데이터 분포 변화에 민감합니다. 위 7가지를 순서대로 점검하면, “왜 갑자기 망가졌는지”를 감으로 추측하지 않고 재현 가능한 방식으로 복구할 수 있습니다.

추가로 HNSW 파라미터별 성능 측정 방법과 실험 설계를 더 보고 싶다면 아래 글을 함께 참고하세요.