왜 ef_search가 문제가 되는가

HNSW 기반 벡터 검색에서 색인은 한 번 만들면 끝이지만, 질의 품질과 지연은 매 요청마다 ef_search(질의 시 탐색 후보 큐 크기)에 좌우된다. 이 값을 크게 잡으면 그래프를 더 넓게 탐색해 재현율(recall)이 올라가지만 지연과 CPU 사용량이 함께 커진다. 반대로 작게 잡으면 빠르지만 상위 K개 결과가 실제 최근접 이웃과 어긋난다.

실무에서 자주 겪는 실패는 두 가지다. 색인 시점의 ef_construction만 튜닝하고 질의 파라미터는 기본값으로 방치하거나, 반대로 재현율이 낮다는 이유로 ef_search를 무작정 올려 p99 지연이 SLO를 넘기는 경우다. 둘 다 파레토 관점 없이 한쪽 축만 본 결과다.

파레토 프론티어로 보기

목표는 "최적값 하나"가 아니라 재현율-지연의 파레토 프론티어를 그리는 것이다. 같은 재현율을 더 낮은 지연으로 얻을 수 있으면 그 설정이 우월하다. ef_search를 스윕하며 각 지점의 recall@K와 p95/p99를 측정하면, SLO(예: p99 < 20ms) 선을 그은 뒤 그 아래에서 재현율이 가장 높은 지점을 고르면 된다.

ef_searchrecall@10p95(ms)p99(ms)
400.913.25.1
800.965.89.4
1280.9858.915.2
2560.99416.128.7

위 예시에서 p99 < 20ms가 SLO라면 128이 프론티어상의 선택이다. 256은 재현율 0.9%p를 위해 지연을 두 배 가까이 지불한다.

측정 하네스 만들기

정답셋(ground truth)은 brute-force로 미리 구해 두고, 동일 질의 집합을 각 ef_search로 반복 측정한다. 재현율은 상위 K 집합의 교집합 비율로 계산한다.

import time, numpy as np

def recall_at_k(approx, truth, k):
    hits = sum(len(set(a[:k]) & set(t[:k])) for a, t in zip(approx, truth))
    return hits / (k * len(truth))

def sweep(index, queries, truth, ef_list, k=10):
    for ef in ef_list:
        index.set_ef(ef)          # 라이브러리별 API 차이 있음
        lat = []
        approx = []
        for q in queries:
            t0 = time.perf_counter()
            ids, _ = index.knn_query(q, k=k)
            lat.append((time.perf_counter() - t0) * 1000)
            approx.append(ids)
        p99 = np.percentile(lat, 99)
        print(f"ef={ef:4d} recall={recall_at_k(approx, truth, k):.3f} "
              f"p50={np.percentile(lat,50):.2f} p99={p99:.2f}ms")

주의점: 측정 전 워밍업 질의를 수십 회 돌려 캐시/JIT 편차를 줄이고, 부하 조건(동시성)을 프로덕션과 맞춰야 한다. 단일 스레드 지연과 QPS 부하 하의 지연은 크게 다르다.

엔진별 설정 위치

ef_search는 색인이 아니라 질의 시점에 조정 가능한 값이라는 점이 핵심이다. 대부분 엔진이 질의당 오버라이드를 지원한다.

# Elasticsearch / OpenSearch (kNN) — num_candidates가 ef 역할
POST my-index/_search
{
  "knn": {
    "field": "embedding",
    "query_vector": [/* ... */],
    "k": 10,
    "num_candidates": 128
  }
}
# Qdrant — 검색 파라미터로 질의당 지정
# search_params: { "hnsw_ef": 128 }
# pgvector — 세션 단위로 설정
# SET hnsw.ef_search = 128;

질의당 오버라이드가 되면 트래픽 유형별로 값을 다르게 줄 수 있다. 예: 사용자 실시간 검색은 낮게, 배치 재랭킹 후보 생성은 높게.

ef_construction과의 관계

ef_search로 도달할 수 있는 재현율 상한은 색인 품질이 결정한다. ef_construction과 M이 낮아 그래프 연결이 빈약하면, ef_search를 아무리 올려도 특정 재현율에서 정체된다. 스윕 곡선이 조기에 평탄해지면 질의 튜닝이 아니라 재색인 신호다. 반대로 색인이 충분히 좋은데 재현율이 낮다면 그건 순수하게 ef_search로 회수할 수 있는 손실이다.

운영 시 주의점

  • 데이터 분포가 바뀌면 프론티어도 이동한다. 임베딩 모델 교체나 대량 삽입 후에는 스윕을 다시 돌린다.
  • 필터링(메타데이터 조건)이 붙으면 유효 후보가 줄어 같은 ef_search라도 재현율이 떨어질 수 있다. 필터 있는 질의는 별도로 측정한다.
  • SLO는 평균이 아니라 p99로 잡는다. 평균 지연은 낮아도 꼬리 지연이 사용자 경험을 결정한다.
  • 재현율 목표는 서비스 요구에서 역산한다. 추천 후보 생성이라면 0.95로 충분하지만, 규정 준수 검색이라면 0.99+가 필요할 수 있다.

결국 ef_search 튜닝은 단일 값 찾기가 아니라, 측정 하네스로 프론티어를 그리고 SLO 제약 아래에서 트래픽별 지점을 선택하는 반복 작업이다.