Published on

Milvus HNSW 튜닝 - recall 올리고 p99 낮추기

Authors

서론: HNSW 튜닝의 목표는 recall↑p99↓의 동시 달성

Milvus에서 벡터 검색 성능을 이야기할 때 흔히 QPS나 평균 지연만 보다가, 실제 서비스에서는 p95/p99가 SLA를 결정합니다. 문제는 HNSW가 정확도(=recall) 를 올리면 대개 탐색량이 늘어 지연이 증가하고, 지연을 줄이면 recall이 떨어지는 트레이드오프가 뚜렷하다는 점입니다.

하지만 HNSW는 파라미터가 비교적 직관적이고, Milvus는 인덱스/검색 파라미터를 분리해 조절할 수 있어 올바른 순서로 튜닝하면 recall을 올리면서도 p99를 낮추는 구간을 찾을 수 있습니다. 이 글은 그 “순서”와 “관측 포인트”에 집중합니다.


HNSW 핵심 파라미터 3가지와 영향 범위

Milvus에서 HNSW를 사용할 때 가장 중요한 파라미터는 아래 3개입니다.

  • M : 노드당 이웃(연결) 수. 그래프가 촘촘해져 탐색이 쉬워져 recall이 오르기 쉬움. 대신 메모리 사용량 증가빌드 비용 증가.
  • efConstruction : 인덱스 빌드 시 후보 탐색 폭. 높을수록 더 좋은 그래프를 만들 가능성이 커져 recall에 유리. 대신 인덱싱 시간/CPU 증가.
  • ef : 검색 시 후보 탐색 폭. 높을수록 recall이 상승하지만 검색 지연이 증가.

정리하면:

  • 빌드 품질을 올리는 축: M, efConstruction
  • 런타임 정확도를 올리는 축: ef

p99 관점에서는 ef가 가장 직접적이지만, MefConstruction을 올려 그래프 품질이 좋아지면 같은 recall을 더 낮은 ef로 달성할 수 있어 결과적으로 p99를 낮출 수 있습니다.


실전 튜닝 전략: “빌드 품질 먼저, ef는 나중에”

1) 기준선(Baseline) 고정: 데이터/쿼리/필터/TopK

튜닝이 실패하는 가장 흔한 이유는 실험 조건이 흔들리는 것입니다.

  • 데이터 스냅샷 고정(동일한 컬렉션/파티션)
  • 쿼리 셋 고정(실서비스 대표 쿼리 N개)
  • topK 고정(예: topK=10)
  • 필터 조건 고정(필터가 있으면 반드시 포함)

특히 필터가 있는 워크로드는 HNSW 자체보다 필터 적용 방식과 후보 수에 의해 p99가 크게 출렁입니다. 필터가 있다면 “필터 선택도(selectivity)”까지 같이 기록하세요.

2) 목표 정의: recall@Kp99를 동시에 수치화

  • 정확도: recall@K (예: recall@10)
  • 지연: p99 latency (ms)
  • 보조 지표: CPU 사용률, RSS 메모리, 디스크 I/O

튜닝은 결국 “목표 recall을 만족하는 최소 p99”를 찾는 최적화 문제입니다.


인덱스 파라미터 튜닝: MefConstruction

권장 접근

  1. M을 먼저 올려 그래프 연결성을 확보
  2. efConstruction으로 그래프 품질을 다듬기
  3. 마지막에 ef로 recall/p99를 미세 조정

경험적 가이드(출발점)

  • M: 8에서 시작해 16, 24, 32로 단계적으로
  • efConstruction: 100에서 시작해 200, 400로 단계적으로

데이터 차원/분포/규모에 따라 다르지만, 보통 M을 너무 낮게 잡으면 ef를 아무리 올려도 recall이 잘 안 오르고 p99만 악화됩니다.

Milvus 인덱스 생성 예시

아래 예시는 Python SDK 기준의 형태를 보여줍니다(환경에 따라 import/연결 코드는 달라질 수 있습니다).

index_params = {
  "index_type": "HNSW",
  "metric_type": "COSINE",
  "params": {
    "M": 16,
    "efConstruction": 200
  }
}

collection.create_index(
  field_name="embedding",
  index_params=index_params
)

metric_type 체크

  • 임베딩이 정규화되어 있다면 COSINE 혹은 IP가 흔합니다.
  • 정규화가 안 되어 있고 유클리드 거리 기반이면 L2.

메트릭을 잘못 고르면 튜닝으로 해결이 안 되는 recall 문제가 발생합니다.


검색 파라미터 튜닝: ef로 recall/p99 곡선을 만든다

HNSW에서 검색 파라미터는 사실상 ef 하나로 요약됩니다. ef를 올리면 recall은 증가하고, 지연도 증가합니다.

추천 실험 방식

  • ef를 로그 스케일로 증가: 16, 32, 64, 128, 256
  • ef에서 recall@Kp99를 측정
  • 목표 recall을 만족하는 최소 ef를 선택

Milvus 검색 예시

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

results = collection.search(
  data=query_vectors,
  anns_field="embedding",
  param=search_params,
  limit=10,
  expr="status == 1"
)

여기서 중요한 점은 expr(필터)가 있을 때입니다. 필터로 후보가 급격히 줄어들면, ef를 올려도 recall이 잘 안 오르거나 p99가 튀는 현상이 생길 수 있습니다. 이 경우는 “HNSW 탐색”이 아니라 “필터 이후 후보 부족”이 병목일 가능성이 큽니다.


recall↑p99↓를 동시에 만드는 레버: “더 좋은 그래프 + 더 낮은 ef”

많은 팀이 ef만 올려서 recall을 맞추려다 p99가 무너집니다. 대신 아래 조합을 시도해보면 같은 recall을 더 낮은 ef로 달성할 가능성이 큽니다.

  1. M16에서 24로 증가
  2. efConstruction200에서 400으로 증가
  3. 그 다음 ef를 다시 내려보며 목표 recall 유지되는 최소값 탐색

이 방식은 인덱스 빌드 비용이 늘지만, 서비스 p99를 안정화시키는 데 효과적입니다.


p99를 흔드는 비(非)HNSW 요인: 세그먼트, 캐시, 메모리

HNSW 파라미터를 잘 잡아도 p99가 들쭉날쭉하면 대개 시스템 레이어 문제가 섞여 있습니다.

1) 메모리 부족과 페이지 폴트

HNSW는 구조상 메모리 접근이 랜덤합니다. 메모리가 부족해 스왑/페이지 폴트가 늘면 p99가 급격히 악화됩니다.

  • RSS가 워킹셋을 감당하는지 확인
  • 컨테이너 환경이면 메모리 제한이 너무 타이트하지 않은지 확인

쿠버네티스에서 메모리/공유메모리 이슈가 있으면 지연이 튈 수 있는데, 워크로드에 따라 /dev/shm 부족이 간접 원인이 되기도 합니다. 관련 진단 관점은 EKS에서 Pod /dev/shm 부족으로 OOM 해결하기 글의 체크리스트가 도움이 됩니다.

2) 파일 디스크립터/네트워크 이슈

Milvus는 내부 컴포넌트 간 통신과 파일 핸들 사용이 많습니다. p99 스파이크가 시스템 리소스 한계로 발생하는 경우도 있습니다.

  • ulimit -n(open files) 확인
  • 노드에서 EMFILE 발생 여부 확인

리눅스에서 FD 고갈은 증상이 다양하게 나타나므로, 원인 파악은 Linux EMFILE(Too many open files) 원인과 해결 내용을 참고해 점검하는 편이 빠릅니다.

3) CrashLoop/재시작으로 인한 캐시 콜드 스타트

p99가 주기적으로 튀고, 동시에 Pod 재시작이 보인다면 튜닝 이전에 안정성부터 잡아야 합니다. 캐시가 날아가거나 세그먼트 로딩이 반복되면 p99는 구조적으로 나빠집니다.

  • 재시작 원인부터 제거

체크리스트는 Kubernetes CrashLoopBackOff 원인 8가지 진단에서 빠르게 훑을 수 있습니다.


필터가 있는 벡터 검색에서의 HNSW 튜닝 포인트

실서비스는 보통 tenant_id, status, category 같은 스칼라 필터가 붙습니다. 이때는 HNSW 파라미터만으로 해결되지 않는 경우가 많습니다.

1) 필터 선택도가 너무 높으면(너무 많이 걸러지면)

  • 특정 테넌트/카테고리에서 후보가 적어 topK 자체가 불안정
  • 결과적으로 recall 측정이 의미 없어지거나, p99가 튀기도 함

가능하면:

  • 파티션/클러스터링 전략으로 검색 공간을 줄이거나
  • 필터 컬럼 인덱싱/데이터 모델을 재검토

2) topK가 커질수록 p99가 급상승

topK가 커지면 단순히 결과를 더 뽑는 문제가 아니라, 내부적으로 후보 유지/정렬 비용이 증가합니다. topK가 큰 API라면 topK별로 별도 튜닝(혹은 별도 인덱스/컬렉션)을 고려해야 합니다.


튜닝 실험을 자동화하는 방법: 그리드 탐색과 기록

HNSW 튜닝은 감으로 하면 끝이 없습니다. 아래처럼 실험을 자동화해 “곡선”을 얻어야 합니다.

  • 인덱스 후보: (M, efConstruction) 조합 3~6개
  • 검색 후보: ef 5~7개
  • 측정: recall@K, p95, p99, CPU, 메모리

간단한 의사코드 예시는 다음과 같습니다.

Ms = [8, 16, 24]
efCs = [100, 200, 400]
efs = [16, 32, 64, 128, 256]

for M in Ms:
  for efC in efCs:
    build_index(M=M, efConstruction=efC)
    for ef in efs:
      metrics = run_benchmark(ef=ef)
      log_result(M, efC, ef, metrics)

이렇게 쌓인 결과에서 “목표 recall을 만족하는 최소 p99”를 고르면 됩니다. 운영 환경에서는 여기에 비용(인덱스 빌드 시간, 메모리 증가분)까지 함께 고려하세요.


추천 튜닝 레시피(출발점) 3가지

레시피 A: 균형형(대부분의 기본값 후보)

  • M=16
  • efConstruction=200
  • ef=64

레시피 B: recall 우선(오프라인 품질 중요)

  • M=24
  • efConstruction=400
  • ef=128

레시피 C: p99 우선(온라인 SLA 빡빡)

  • M=16
  • efConstruction=400 (그래프 품질로 ef를 낮추기)
  • ef=32부터 시작해 목표 recall까지 최소로 올리기

레시피 C의 핵심은 “빌드에서 투자하고 런타임 탐색량을 줄여 p99를 방어”하는 접근입니다.


결론: HNSW 튜닝은 ef가 아니라 “그래프 품질과 워크로드”의 문제

Milvus HNSW에서 recall↑p99↓를 동시에 노리려면,

  1. MefConstruction으로 좋은 그래프를 만들고
  2. ef는 목표 recall을 만족하는 최소값으로 제한하며
  3. 필터/메모리/재시작 같은 시스템 요인으로 p99가 튀지 않게 받쳐줘야 합니다.

마지막으로, 튜닝 결과는 데이터 분포와 쿼리 패턴이 바뀌면 다시 깨집니다. 실서비스라면 대표 쿼리 셋을 주기적으로 갱신하고, recall@Kp99를 함께 리그레션 테스트로 돌리는 체계를 만드는 것이 가장 큰 성능 개선입니다.