Published on

Milvus HNSW 튜닝으로 RAG 정확도·지연 최적화

Authors

RAG에서 검색 품질이 흔들리면, 프롬프트를 아무리 다듬어도 답변이 엉뚱해집니다. 반대로 검색을 과하게 정확하게 만들면 지연이 튀고 비용이 늘어납니다. Milvus를 쓰는 팀이라면 결국 HNSW 튜닝으로 정확도(Recall)지연(P95/P99) 사이의 균형점을 찾아야 합니다.

이 글은 Milvus HNSW를 기준으로, 인덱스 빌드 파라미터(M, efConstruction)와 쿼리 파라미터(efSearch)를 어떻게 실험하고 운영값으로 굳히는지, 그리고 RAG에서 체감 품질을 올리는 실전 팁을 정리합니다.

문맥상 함께 보면 좋은 글:

HNSW가 RAG에서 중요한 이유

HNSW는 그래프 기반 근사 최근접 탐색(ANN)입니다. RAG에서는 보통 다음 흐름으로 검색이 일어납니다.

  1. 질의 임베딩 생성
  2. 벡터 DB에서 TopK 검색
  3. 결과 문서 조합 및 재랭킹(optional)
  4. LLM에 컨텍스트로 주입

여기서 2번의 품질이 나쁘면, 3번에서 재랭킹을 해도 후보군 자체가 틀려서 복구가 어렵습니다. 즉, HNSW의 목표는 “TopK 후보군이 충분히 정답을 포함하도록(높은 Recall) 하면서도, 지연을 제한하는 것”입니다.

튜닝 전에 먼저 정해야 하는 목표 지표

튜닝은 숫자를 고정하지 않으면 끝이 없습니다. 아래 3가지를 먼저 합의하세요.

1) 오프라인 검색 품질 지표

  • Recall@K: 정답 문서가 TopK에 포함되는 비율
  • MRR@K 또는 nDCG@K: 정답의 순위까지 반영
  • RAG 특화: Hit@K(정답 chunk 포함 여부), Answerable@K(정답을 만들 수 있는 근거 포함 여부)

정답 라벨이 없다면, 최소한 “FAQ/티켓/위키” 같은 내부 QA 세트 200~1000개라도 만들어야 합니다.

2) 온라인 지연/비용 지표

  • P50/P95/P99 latency(검색 API 단독)
  • CPU 사용률, 메모리, 디스크 IO, 네트워크
  • QPS 대비 tail latency(특히 P99)

3) 운영 안정성 지표

  • 인덱스 빌드 시간(재빌드/리밸런싱 시 영향)
  • 메모리 상주율(그래프가 메모리에 못 올라가면 지연이 급격히 악화)

Milvus HNSW 핵심 파라미터 3종 세트

HNSW 튜닝은 사실상 아래 3개로 요약됩니다.

M: 그래프 연결 수(대략적인 분기)

  • 의미: 각 노드가 유지하는 이웃(edge) 수
  • 효과: M이 커질수록 그래프가 촘촘해져 Recall이 좋아지지만, 메모리와 빌드 비용이 증가
  • RAG에서 체감: M이 너무 작으면 특정 주제에서 “후보군이 아예 엇나가는” 케이스가 늘어남

일반적인 출발점은 M=16 또는 M=32입니다. 임베딩 차원이 크고(예: 1024 이상), 데이터 분포가 복잡하거나(다도메인), 필터링이 많아 후보군이 줄어드는 경우 M=32가 유리한 편입니다.

efConstruction: 인덱스 빌드 품질

  • 의미: 인덱스 생성 시 탐색 폭(더 많이 탐색할수록 더 좋은 그래프)
  • 효과: efConstruction 증가 => Recall 상승 가능, 빌드 시간/CPU 증가
  • 운영 포인트: 온라인 쿼리 지연에는 직접 영향이 거의 없지만, “그래프 품질의 상한”을 결정

출발점은 efConstruction=200 전후가 무난하고, 품질이 부족하면 300~500까지 올려봅니다.

efSearch: 쿼리 탐색 폭(가장 민감)

  • 의미: 검색 시 후보를 얼마나 넓게 탐색할지
  • 효과: efSearch 증가 => Recall 상승, 지연 증가
  • RAG에서 체감: efSearch는 “정확도-지연” 트레이드오프의 다이얼

운영에서는 efSearch를 고정값으로 두기보다, 트래픽/필터/TopK에 따라 동적으로 조정하는 전략이 꽤 효과적입니다.

Milvus에서 HNSW 인덱스 생성 예시

아래는 Python SDK 기준의 예시입니다. (버전마다 API가 조금씩 다를 수 있으니, 핵심은 index_typeparams 구조입니다.)

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

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

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

schema = CollectionSchema(fields, description="RAG chunks")
col = Collection(name="rag_chunks", schema=schema)

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {
        "M": 32,
        "efConstruction": 300
    }
}

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

metric_type은 임베딩 모델에 따라 달라집니다.

  • 정규화된 임베딩이면 COSINE
  • L2 기반으로 학습된 모델이면 L2
  • 내적이 의미 있는 모델이면 IP

중요한 건 “모델이 의도한 metric”과 “검색 metric”을 일치시키는 것입니다. 여기서 어긋나면 HNSW를 아무리 튜닝해도 품질이 불안정합니다.

검색 파라미터(efSearch) 적용 예시

Milvus는 검색 시 search_paramsef(또는 efSearch)를 넘깁니다.

query_vec = [0.0] * 768  # 예시

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

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

여기서 limit(TopK)를 10으로 두면서 ef를 32로 두면, 탐색 폭이 너무 좁아 Recall이 떨어질 수 있습니다. 경험적으로 ef는 최소한 TopK보다 충분히 커야 합니다(예: efTopK의 5~20배 구간에서 실험).

튜닝 전략: 한 번에 하나씩, 그러나 순서는 정해져 있다

현장에서 가장 흔한 실패는 “M, efConstruction, efSearch를 동시에 바꾸면서 감으로 고정”하는 것입니다. 아래 순서가 재현성과 효율이 좋습니다.

1) MefConstruction으로 그래프 품질의 상한을 만든다

  • 목표: 적당한 빌드/메모리 비용 안에서 “충분히 높은 Recall이 나올 수 있는 그래프”를 만든다
  • 방법: M을 16/32로 2개만 두고, efConstruction을 200/300/500 정도로 스윕

이 단계에서는 efSearch를 넉넉하게(예: 256 또는 512) 줘서 “그래프가 낼 수 있는 최대 품질”을 관찰합니다.

2) efSearch로 정확도-지연 균형점을 찾는다

  • 목표: SLO(예: P95 80ms) 안에서 Recall 목표(예: Recall@10 0.95)를 만족
  • 방법: efSearch만 32/64/96/128/192/256처럼 단계적으로 올리며 측정

3) 트래픽/필터 조건별로 efSearch를 동적으로 조절한다

RAG 서비스에서는 조건에 따라 난이도가 달라집니다.

  • 필터가 강하면(예: tenant_id + doc_type) 후보군이 줄어 탐색이 어려워져 efSearch를 올려야 하는 경우가 있음
  • TopK를 크게 요청하면(예: rerank를 위해 Top50) efSearch도 함께 올려야 함
  • 피크 타임에는 efSearch를 낮춰 tail latency를 방어하고, 대신 rerank나 후처리로 보정하는 전략도 가능

실험 설계: 오프라인과 온라인을 분리해서 본다

오프라인 벤치마크 체크리스트

  • 데이터: 실제 운영 분포를 반영(도메인별 비율, 문서 길이, chunk 크기)
  • 쿼리: “사용자가 실제로 던지는 질문” 형태(짧은 키워드 vs 자연어)
  • 정답 라벨: 최소한 query -> relevant doc_id 매핑

오프라인에서는 아래를 표로 뽑아 비교합니다.

  • M, efConstruction, efSearch
  • Recall@10, MRR@10
  • 평균 지연, P95 지연
  • 메모리 사용량(가능하면)

온라인 검증(점진 롤아웃)

오프라인에서 좋아 보여도, 온라인에서는 캐시/동시성/필터 분포 때문에 지연이 튈 수 있습니다.

  • canary로 5% -> 25% -> 100% 점진 적용
  • 검색 품질은 “LLM 답변 품질”로도 간접 측정(예: 사용자 피드백, 재질문율)

RAG 관점에서 자주 놓치는 튜닝 포인트

1) chunk 전략이 HNSW 튜닝을 압도하는 경우

chunk가 너무 크면 임베딩이 평균화되어 구분력이 떨어지고, 너무 작으면 문맥이 부족해 정답 근거가 잘리지 않습니다. HNSW를 올리기 전에 최소한 아래를 점검하세요.

  • chunk 크기(예: 300800 토큰)와 overlap(예: 50150 토큰)
  • 제목/섹션 헤더를 chunk에 포함했는지
  • 표/코드/리스트 전처리(의미 손실 여부)

2) 필터링이 있으면 efSearch가 더 필요해진다

Milvus에서 scalar filter를 강하게 걸면, 탐색 중 만나는 후보가 필터로 탈락하면서 “실제 유효 후보”를 찾기 위해 더 넓게 탐색해야 합니다. 이때 품질 저하를 M 문제로 오해하고 인덱스를 과대하게 만드는 일이 많습니다.

실전 팁:

  • 필터가 있는 쿼리만 따로 efSearch를 상향
  • TopK를 늘려 rerank 후보군을 확보(단, 지연/비용 고려)

3) TopK와 rerank의 역할 분리

  • 1차 검색(HNSW): Recall을 확보
  • 2차 rerank(BGE reranker 등): Precision을 확보

즉, HNSW는 정답을 후보군에 넣는 것이 1차 목표입니다. Precision까지 HNSW에서 과하게 해결하려고 efSearch를 올리면 지연이 급격히 증가합니다.

추천 시작값(현장 베이스라인)

데이터 규모/차원/하드웨어에 따라 달라지지만, 시작점으로는 아래 조합이 실패 확률이 낮습니다.

  • 임베딩 768차원, 수백만 chunk, COSINE 기준

    • M=32
    • efConstruction=300
    • efSearch=96부터 시작해서 64/96/128/192 스윕
  • 임베딩 1536차원(예: OpenAI 계열), 분포 복잡

    • M=32 또는 M=48
    • efConstruction=300~500
    • efSearch=128부터 시작

만약 메모리가 빠듯하면 M을 올리기보다 먼저 efSearch를 조정해 보세요. M 증가는 메모리 비용이 상수처럼 따라붙는 반면, efSearch는 트래픽/정책에 따라 조절할 여지가 큽니다.

운영 체크리스트: 지연이 튈 때 어디부터 볼까

HNSW 튜닝을 했는데도 P99가 튄다면, 알고리즘보다 시스템 이슈일 때가 많습니다.

1) 컬렉션 로드 상태와 워밍업

  • 인덱스가 메모리에 안정적으로 올라와 있는지
  • 배포 직후 cold start에서 지연이 튀는지

2) 동시성 증가 시 tail latency

  • 같은 efSearch라도 동시 요청이 늘면 CPU contention으로 P99가 급등
  • 검색 워커 수, 리소스 제한, 노드 간 분산을 재점검

3) 네트워크 및 게이트웨이 타임아웃

RAG는 검색 API가 LLM 호출과 체인으로 묶입니다. 검색이 100ms 늘면 전체 체감은 더 커집니다. 게이트웨이/로드밸런서 타임아웃도 함께 보세요.

실전: efSearch 동적 조절 패턴

아래는 “필터 여부 + TopK + 부하”로 efSearch를 조절하는 단순한 정책 예시입니다.

def choose_ef_search(topk: int, has_filter: bool, load_level: str) -> int:
    # load_level: "low" | "mid" | "high"
    base = 8 * topk  # TopK에 비례

    if has_filter:
        base = int(base * 1.5)

    if load_level == "high":
        # 피크에는 지연 방어: 탐색 폭을 줄이고 rerank/후처리로 보완
        base = int(base * 0.7)

    # 안전 범위 클램프
    return max(32, min(base, 256))

이런 정책은 “항상 최고 Recall”을 목표로 하지 않습니다. 대신 SLO를 지키면서 평균 품질을 유지하고, 어려운 쿼리(필터 강함, TopK 큼)에서만 비용을 쓰는 방향입니다.

결론: HNSW 튜닝의 정답은 ‘세트’로 고정하는 것

Milvus HNSW 튜닝은 단일 파라미터 최적화가 아니라, 아래 세트를 목표 지표에 맞춰 고정하는 작업입니다.

  • 인덱스 품질 상한: M, efConstruction
  • 온라인 트레이드오프 다이얼: efSearch
  • RAG 품질의 바닥을 올리는 보조축: chunk/필터/rerank

추천 흐름은 간단합니다.

  1. M=16/32 두 개만 비교
  2. efConstruction=200/300/500으로 그래프 품질 상한 확보
  3. efSearch를 스윕하며 Recall 목표와 P95 목표의 교차점을 찾기
  4. 필터/부하 조건에 따라 efSearch를 정책적으로 가변화

이 과정을 거치면 “검색정확도는 올랐는데 지연이 감당 안 됨” 또는 “지연은 좋은데 답이 자꾸 빗나감” 같은 흔한 함정에서 벗어나, RAG 전체 품질을 안정적으로 끌어올릴 수 있습니다.