Published on

Milvus HNSW 리콜 하락? IVF_PQ로 정확도 복구

Authors

서빙 중인 RAG 또는 유사도 검색에서 어느 날부터 HNSW 기반 컬렉션의 리콜이 체감상 떨어지는 일이 있습니다. 같은 쿼리인데 상위 topK 결과가 흔들리고, 정답 문서가 top10 밖으로 밀리면서 LLM 답변 품질이 급락합니다. 이때 흔히 HNSW 파라미터를 더 올리는 방식으로만 접근하는데, 데이터 분포나 메모리 제약 때문에 한계가 명확합니다.

이 글은 HNSW 리콜 하락을 “운영 이슈”로 진단하는 체크리스트를 제시하고, IVF_PQ로 정확도를 복구하는 실전 접근을 단계별로 정리합니다. HNSW 튜닝 자체가 필요하다면 먼저 아래 글도 함께 보시면 좋습니다.

HNSW 리콜이 떨어지는 “진짜” 원인들

HNSW는 메모리 기반 그래프 인덱스라서, 단순히 efSearch를 올리면 리콜이 개선되는 경우가 많습니다. 하지만 운영 환경에서는 다음 요인들이 겹치면서 파라미터를 올려도 리콜이 안 돌아오는 상황이 자주 발생합니다.

1) 데이터 분포가 바뀌었다

  • 임베딩 모델 변경(차원, 분포, 정규화 여부)
  • 문서 도메인 확장으로 클러스터 구조가 달라짐
  • 중복 데이터 급증으로 근접 이웃이 과밀해짐

HNSW는 “그래프 탐색”이기 때문에, 데이터가 특정 영역에 과밀해지면 탐색이 국소적으로 갇히는 현상이 커질 수 있습니다.

2) 메모리 압박과 캐시 미스

HNSW는 인덱스가 기본적으로 메모리에 상주해야 성능과 리콜이 안정적입니다. 운영 중 메모리 압박이 생기면

  • 페이지 폴트 증가
  • OS 캐시 경쟁
  • 동시에 여러 컬렉션 로드 같은 이유로 탐색 효율이 떨어지고, 결과적으로 제한된 시간 안에 충분히 탐색하지 못해 리콜이 하락하는 것처럼 보일 수 있습니다.

3) 세그먼트 증가와 인덱스 상태 불균일

Milvus는 데이터가 세그먼트 단위로 관리됩니다. 인덱스 빌드가 덜 된 세그먼트가 섞이거나, 작은 세그먼트가 과도하게 많아지면 검색이 분산되어 효율이 떨어질 수 있습니다.

4) 파라미터가 “리콜”이 아니라 “지연”을 먼저 때린다

HNSW에서 efSearch를 크게 올리면 리콜은 오르지만 지연이 먼저 폭발합니다. 지연 제한이 있는 환경에서는 타임아웃 또는 상위 레벨에서의 컷오프로 인해 오히려 리콜이 떨어진 것처럼 관측됩니다.

왜 IVF_PQ가 ‘정확도 복구’에 유리한가

IVF_PQ는 크게 두 단계로 생각하면 이해가 쉽습니다.

  1. IVF: 벡터 공간을 nlist개의 버킷으로 나누고, 쿼리 시 nprobe개 버킷만 탐색
  2. PQ: 각 벡터를 압축 코드로 저장해 메모리 사용량을 크게 줄임

이 구조는 운영에서 다음 이점이 큽니다.

  • 메모리 압박 완화: PQ 압축으로 인덱스 메모리를 줄여 캐시 안정성 개선
  • 리콜 제어가 직관적: nprobe를 올리면 더 많은 버킷을 보므로 리콜이 안정적으로 상승
  • 대규모 데이터에서 예측 가능: 그래프 탐색보다 성능 특성이 더 선형적이고, 튜닝이 덜 “감”에 의존

물론 IVF_PQ는 근사 검색이며, PQ 압축이 심하면 정확도가 떨어질 수 있습니다. 핵심은 PQ를 과하게 압축하지 않고, IVF 파라미터를 함께 설계해 “운영 가능한 지연” 안에서 리콜을 끌어올리는 것입니다.

전환 전략: HNSW를 버릴 필요는 없다

현실적으로는 아래 3가지 패턴이 많습니다.

  • 패턴 A: HNSW 단독에서 IVF_PQ 단독으로 교체
  • 패턴 B: 컬렉션을 복제해 IVF_PQ를 병행 운영 후 트래픽 전환
  • 패턴 C: 2단계 검색, 1차 IVF_PQ로 후보를 넓게 뽑고 2차로 재랭킹

Milvus에서 “재랭킹”은 보통 애플리케이션 레벨에서 원본 벡터로 정확한 거리 계산을 한 번 더 하는 방식으로 구현합니다. IVF_PQ가 후보를 넓게 주고, 최종 정밀도는 재랭킹으로 보정하는 설계가 안정적입니다.

IVF_PQ 설계 핵심 파라미터

1) nlist: 버킷 수

  • 너무 작으면 각 버킷이 커져서 후보가 많아지고 지연이 증가
  • 너무 크면 학습 및 인덱스 품질이 불안정해지고, nprobe를 올려야 리콜이 나옴

경험적으로는 데이터 수 N에 대해

  • nlistsqrt(N) 근처에서 시작
  • 또는 N / 1000 수준에서 시작 같은 휴리스틱이 많이 쓰입니다. 중요한 건 “정답이 들어있는 버킷을 nprobe로 충분히 커버”할 수 있게 잡는 것입니다.

2) nprobe: 탐색할 버킷 수

리콜과 지연을 가장 직접적으로 바꾸는 레버입니다.

  • 리콜이 낮으면 nprobe를 올리는 게 1순위
  • 지연이 높으면 nprobe를 낮추고 topK 또는 재랭킹 전략을 조정

3) PQ의 m: 서브벡터 개수

m이 커질수록 더 많은 코드로 표현하므로 정확도가 좋아지지만, 메모리와 연산이 증가합니다.

예를 들어 차원이 768이면 m을 96으로 두면 서브벡터 차원은 768 / 96 = 8이 됩니다. 보통 서브벡터 차원을 8 또는 16 정도로 맞추는 구성이 무난합니다.

4) nbits: 서브벡터당 비트 수

대부분 8을 기본으로 시작합니다. nbits를 낮추면 압축이 강해져 정확도가 떨어질 수 있습니다.

Milvus에서 IVF_PQ 인덱스 생성 예제

아래 예시는 Python SDK 기준의 전형적인 흐름입니다. 환경에 따라 API가 다를 수 있으니, 핵심은 파라미터 구조를 참고하는 것입니다.

from pymilvus import (
    connections, FieldSchema, CollectionSchema, DataType,
    Collection
)

connections.connect(alias="default", host="localhost", port="19530")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=128),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]

schema = CollectionSchema(fields, description="docs with ivf_pq")
col = Collection(name="docs_ivf_pq", schema=schema)

index_params = {
    "index_type": "IVF_PQ",
    "metric_type": "COSINE",
    "params": {
        "nlist": 4096,
        "m": 96,
        "nbits": 8
    }
}

col.create_index(field_name="embedding", index_params=index_params)
col.load()

검색 시에는 nprobe를 쿼리마다 조절할 수 있습니다.

search_params = {
    "metric_type": "COSINE",
    "params": {
        "nprobe": 32
    }
}

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

여기서 운영 팁은 단순합니다.

  • 리콜이 낮으면 nprobe16에서 32, 64로 단계적으로 올려 측정
  • 지연이 한계면 nprobe를 낮추고 limit을 늘린 뒤 애플리케이션에서 재랭킹

“정확도 복구”를 위한 검증 방법: 리콜을 수치로 만들기

HNSW에서 IVF_PQ로 바꾸면 팀 내에서 가장 많이 나오는 질문이 “정확도가 진짜 좋아졌냐”입니다. 이때 온라인 지표만 보면 노이즈가 큽니다. 오프라인에서 정답 셋을 만들어 리콜을 측정해야 합니다.

1) 골든 쿼리셋 만들기

  • 실제 사용자 쿼리 로그에서 대표 쿼리 샘플링
  • 각 쿼리에 대해 정답 문서 doc_id를 1개 이상 라벨링
  • 최소 수백 건 이상 권장

2) 리콜 정의

예를 들어 top10 기준 리콜은 다음처럼 계산합니다.

  • 쿼리별로 정답이 결과 top10에 있으면 1, 아니면 0
  • 전체 평균

3) HNSW vs IVF_PQ 비교 실험

  • 동일 데이터 스냅샷
  • 동일 임베딩
  • 동일 topK
  • HNSW는 efSearch를 여러 값으로
  • IVF_PQ는 nprobe를 여러 값으로

이렇게 그리드로 비교하면 “리콜을 회복하는데 필요한 비용”이 명확해집니다.

추천 튜닝 시나리오

시나리오 1: HNSW가 메모리 한계에 부딪힌 경우

  • 목표: 리콜을 유지하면서 메모리 사용량을 낮추기
  • 접근: IVF_PQ로 전환, m을 충분히 크게 잡아 PQ 손실을 줄이고 nprobe로 리콜 보정

권장 시작점 예시

  • nlist: 2048 또는 4096
  • m: 차원 768 기준 96
  • nbits: 8
  • nprobe: 16부터 시작해 리콜 목표까지 상승

시나리오 2: 데이터가 커져서 HNSW 지연이 불안정해진 경우

  • 목표: 지연 상한을 안정화
  • 접근: IVF_PQ에서 nprobe를 제한해 지연을 캡하고, limit을 늘린 뒤 재랭킹

재랭킹은 보통 다음 형태입니다.

import numpy as np

def cosine(a, b):
    a = a / (np.linalg.norm(a) + 1e-12)
    b = b / (np.linalg.norm(b) + 1e-12)
    return float(np.dot(a, b))

# 1) Milvus에서 IVF_PQ로 후보를 넓게 가져오기
# limit을 50~200 등으로 늘림

# 2) 애플리케이션에서 원본 벡터로 정확 스코어 재계산 후 상위 topK 선택
reranked = sorted(
    candidates,
    key=lambda item: cosine(query_vec, item["embedding_raw"]),
    reverse=True,
)[:10]

주의할 점은 MDX 렌더링 환경에서 각종 특수 기호가 JSX로 오해될 수 있으니, 문서화할 때는 topK, nprobe 같은 토큰은 인라인 코드로 고정하는 습관이 안전합니다.

운영 체크리스트: IVF_PQ로 바꿨는데도 리콜이 낮다면

  1. 임베딩 정규화 확인
  • COSINE을 쓰는데 벡터 정규화를 안 했다면 분포가 흔들릴 수 있습니다.
  1. nlist가 과도하게 큰지
  • nlist가 너무 크면 nprobe를 올려도 정답 버킷을 못 잡는 케이스가 늘 수 있습니다.
  1. PQ 압축이 과한지
  • m이 너무 작거나 nbits가 낮으면 손실이 커집니다.
  1. 세그먼트/인덱스 빌드 상태
  • 일부 세그먼트만 인덱스가 없거나 로드가 덜 되면 결과가 불안정합니다.
  1. 평가 셋이 최신 분포를 반영하는지
  • “리콜이 떨어졌다”는 체감은 종종 쿼리 분포 변화에서 옵니다. 골든셋을 주기적으로 갱신하세요.

마무리: HNSW 리콜 하락을 ‘인덱스 선택’으로 풀기

HNSW는 여전히 강력하지만, 데이터가 커지고 메모리 제약이 커질수록 그래프 기반 인덱스는 운영 변동성에 취약해질 수 있습니다. 이때 IVF_PQ는

  • 메모리를 줄이고
  • 리콜을 nprobe로 예측 가능하게 제어하고
  • 필요하면 재랭킹으로 정밀도를 복구 하는 방식으로 정확도 문제를 “운영 가능한 형태”로 바꿔줍니다.

권장 액션 플랜은 다음 순서입니다.

  1. 골든 쿼리셋으로 리콜을 수치화
  2. IVF_PQ에서 nlist, m, nbits를 보수적으로 잡고 시작
  3. nprobe를 단계적으로 올려 리콜 목표 달성 지점을 찾기
  4. 지연이 한계면 후보 확장 limit과 재랭킹으로 균형 맞추기

이 과정을 거치면 “HNSW 리콜 하락”이라는 모호한 장애를, 재현 가능한 실험과 튜닝으로 안정적으로 복구할 수 있습니다.