Published on

Haystack+Qdrant RAG 품질 튜닝 - HNSW·MMR

Authors

RAG에서 답변 품질이 흔들릴 때, 많은 팀이 LLM 프롬프트나 리랭커부터 만지지만 실제로는 벡터 검색 단계의 재현율과 다양성이 더 큰 원인인 경우가 많습니다. 특히 Haystack을 오케스트레이션 레이어로 쓰고 Qdrant를 벡터 스토어로 쓰는 구성에서는, 다음 두 축이 검색 품질을 좌우합니다.

  • HNSW(근사 최근접 탐색) 파라미터: 재현율(recall)과 지연시간(latency)의 트레이드오프
  • MMR(Maximal Marginal Relevance): 상위 결과의 중복을 줄이고 문맥 커버리지를 넓히는 다양성 제어

이 글은 “왜 비슷한 조각만 잔뜩 뽑히는지”, “왜 어떤 질문은 근거를 못 찾는지”, “왜 트래픽이 오르면 검색이 흔들리는지”를 HNSW·MMR 관점에서 진단하고 튜닝하는 절차를 실전적으로 정리합니다.

관련해서 RAG의 반복·환각과 디버깅 체크리스트는 아래 글도 함께 보면 좋습니다.


1) Haystack+Qdrant 검색 파이프라인에서 품질이 깨지는 지점

일반적인 흐름은 다음과 같습니다.

  1. 쿼리 임베딩 생성
  2. Qdrant에서 top_k 벡터 검색(HNSW 기반)
  3. (선택) 필터링, 하이브리드, 리랭킹
  4. (선택) MMR로 다양화
  5. LLM에 컨텍스트로 주입

여기서 품질 이슈는 보통 다음 패턴으로 나타납니다.

  • 근거 누락: 정답이 있는 문서가 있는데 검색 결과에 안 뜸(재현율 부족)
  • 중복 컨텍스트: 같은 문서의 비슷한 청크가 상위 k를 점유(다양성 부족)
  • 불안정한 결과: 트래픽이나 데이터 증가 후 검색 품질이 흔들림(인덱스/파라미터 불일치)

이 글은 2번과 4번을 집중적으로 다룹니다.


Qdrant는 기본적으로 HNSW 인덱스를 사용합니다. HNSW는 “완전탐색”이 아니라 그래프 기반 근사 탐색이므로 파라미터에 따라 결과 품질이 달라집니다.

2.1 m: 그래프의 연결도(메모리와 재현율)

  • 의미: 각 노드가 가지는 최대 이웃 수(연결 수)
  • 효과:
    • m이 커질수록 그래프가 촘촘해져 재현율이 올라갈 가능성이 큼
    • 대신 메모리 사용량 증가, 구축 시간 증가

실무 감각으로는 다음처럼 접근합니다.

  • 데이터가 작고 품질이 최우선이면 m을 올리는 튜닝이 유효
  • 데이터가 크고 비용이 민감하면 m은 보수적으로 두고 ef_search로 타협점을 찾는 편이 안전

2.2 ef_construct: 인덱스 구축 품질

  • 의미: 인덱스 생성 시 후보를 얼마나 넓게 볼지
  • 효과:
    • ef_construct가 높을수록 더 좋은 그래프를 만들어 검색 재현율이 좋아질 수 있음
    • 대신 인덱싱 시간이 늘어남

중요한 포인트는, ef_construct는 한 번 낮게 만들면 나중에 ef_search만 올려서 완전히 보상하기 어려운 경우가 있다는 점입니다. 즉, 인덱스 품질의 바닥을 결정합니다.

2.3 ef_search: 쿼리 시 탐색 폭(지연시간과 재현율)

  • 의미: 검색 시 후보를 얼마나 넓게 탐색할지
  • 효과:
    • ef_search가 높을수록 재현율 상승, 대신 지연시간 증가

서비스 운영 관점에서 가장 자주 조정하는 값이 ef_search입니다. 트래픽이 늘어 지연시간이 문제면 ef_search를 낮추고, 품질이 문제면 올리는 식으로 SLO에 맞춰 움직이기 쉽습니다.


3) 튜닝의 목표를 지표로 고정하기: Recall, nDCG, 중복률

HNSW·MMR 튜닝은 “느낌”으로 하면 끝이 없습니다. 최소한 아래 3가지를 수치화하는 것을 권장합니다.

  • Recall@k: 정답 문서(또는 정답 청크)가 상위 k에 포함되는 비율
  • nDCG@k: 관련도가 높은 순으로 잘 정렬되는지(라벨이 있으면 강력)
  • 중복률(near-duplicate rate): 상위 k의 청크가 동일 문서/동일 섹션에 과도하게 몰리는지

라벨이 없다면, 운영 로그에서 다음 대체 지표를 씁니다.

  • “LLM이 근거를 못 찾았다” 유형의 실패율
  • 컨텍스트 내 유니크 문서 수, 유니크 섹션 수

4) Qdrant 컬렉션 생성 시 HNSW 설정 예시

아래는 Qdrant Python 클라이언트로 컬렉션을 만들 때 HNSW 파라미터를 설정하는 예시입니다. (버전에 따라 클래스/필드명이 약간 다를 수 있으니 사용 중인 SDK 문서를 확인하세요.)

from qdrant_client import QdrantClient
from qdrant_client.http.models import (
    Distance,
    VectorParams,
    HnswConfigDiff,
)

client = QdrantClient(url="http://localhost:6333")

COLLECTION = "docs"
DIM = 768

client.recreate_collection(
    collection_name=COLLECTION,
    vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(
        m=32,
        ef_construct=256,
        full_scan_threshold=10000,
    ),
)

튜닝 시작점으로는 다음 가이드를 많이 씁니다.

  • m: 16에서 시작해서 32까지 올려보며 메모리/품질 확인
  • ef_construct: 128 또는 256부터 시작
  • ef_search: 운영 시점에 동적으로 올리고 내리기

full_scan_threshold는 데이터가 작을 때 완전탐색으로 전환하는 임계값인데, 개발/테스트 환경에서는 품질 비교에 도움이 되지만 운영에서는 일관성 있는 성능을 위해 신중히 설정해야 합니다.


5) Haystack에서 Qdrant 검색 + ef_search를 쿼리 단위로 제어하기

실전에서는 “질문 난이도”에 따라 검색 비용을 다르게 주는 전략이 유효합니다.

  • 짧고 모호한 질문: ef_search를 올려 재현율 확보
  • 명확하고 키워드가 강한 질문: ef_search를 낮춰 지연시간 절감

Haystack에서 Qdrant를 붙이는 방식은 버전별로 다르지만, 핵심은 Qdrant 검색 파라미터를 search_params로 전달하는 것입니다.

아래는 개념 예시입니다(실제 클래스명은 Haystack 버전에 맞게 조정).

# 개념 예시: Qdrant 검색 시 ef_search를 조정

from qdrant_client.http.models import SearchParams


def retrieve_with_ef(client, collection, query_vector, top_k, ef_search):
    return client.search(
        collection_name=collection,
        query_vector=query_vector,
        limit=top_k,
        search_params=SearchParams(hnsw_ef=ef_search, exact=False),
    )

# 난이도 높은 질문일수록 ef_search를 높게
hits = retrieve_with_ef(
    client=client,
    collection=COLLECTION,
    query_vector=[0.01] * DIM,
    top_k=20,
    ef_search=256,
)

운영 팁은 다음과 같습니다.

  • top_kef_search는 같이 움직입니다. top_k를 늘리면서 ef_search가 낮으면 “많이 뽑는데 품질은 나쁜” 상태가 됩니다.
  • 장애/지연시간 이슈가 있으면 검색 단계에서 타임아웃과 재시도 정책도 같이 봐야 합니다. 특히 LLM 호출과 벡터 검색이 동시에 느려지면 전체 체감이 급격히 나빠집니다.

API 재시도·백오프 패턴은 아래 글의 원칙을 그대로 적용할 수 있습니다.


6) MMR로 “비슷한 청크만 잔뜩” 문제 해결하기

HNSW 튜닝으로 재현율을 올려도, 상위 결과가 특정 문서의 유사 청크로 도배되는 문제가 남습니다. 이때 MMR이 효과적입니다.

MMR은 간단히 말해 다음을 동시에 최적화합니다.

  • 쿼리와의 관련성(유사도)
  • 이미 선택된 결과들과의 비유사성(다양성)

보통 아래 형태로 표현합니다.

  • 점수 = lambda * sim(쿼리, 후보) - (1 - lambda) * max sim(후보, 선택된 결과)

여기서 lambda가 높으면 “관련성 우선”, 낮으면 “다양성 우선”입니다.

6.1 MMR 적용 위치: 리트리버 뒤, LLM 앞

가장 흔한 패턴은 다음입니다.

  1. Qdrant에서 top_k를 크게 가져옴(예: 40)
  2. MMR로 final_k로 줄임(예: 12)
  3. 그 12개를 컨텍스트로 주입

즉, 후보 풀은 넉넉히, 최종 컨텍스트는 다양하게가 핵심입니다.

6.2 파이썬으로 MMR 간단 구현 예시

아래는 코사인 유사도를 사용한 MMR 샘플입니다. 임베딩 벡터가 이미 있고, 후보 문서의 벡터도 있다고 가정합니다.

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))


def mmr(query_vec, doc_vecs, top_k=12, lambda_mult=0.7):
    selected = []
    candidates = list(range(len(doc_vecs)))

    # 쿼리-문서 유사도 미리 계산
    q_sims = [cosine(query_vec, doc_vecs[i]) for i in candidates]

    while candidates and len(selected) < top_k:
        best = None
        best_score = -1e9

        for i in candidates:
            relevance = q_sims[i]
            diversity = 0.0
            if selected:
                diversity = max(cosine(doc_vecs[i], doc_vecs[j]) for j in selected)

            score = lambda_mult * relevance - (1.0 - lambda_mult) * diversity
            if score > best_score:
                best_score = score
                best = i

        selected.append(best)
        candidates.remove(best)

    return selected

튜닝 팁:

  • lambda_mult 기본값은 0.6에서 0.8 사이를 많이 씁니다.
  • “중복이 심하다”면 lambda_mult를 낮추고, “관련 없는 게 섞인다”면 높입니다.
  • MMR은 후보 풀의 품질이 낮으면 오히려 관련 없는 문서를 섞을 수 있으니, HNSW 재현율을 먼저 확보하는 편이 안전합니다.

7) HNSW와 MMR을 같이 튜닝하는 실전 절차

아래 순서가 시행착오를 줄입니다.

7.1 1단계: HNSW 재현율 바닥 올리기

  • ef_search를 충분히 크게 올려서(예: 256 또는 512) 품질 상한을 확인
  • 그 상태에서 Recall@k가 낮다면:
    • 임베딩 모델/전처리/청킹 문제일 수 있음
    • 또는 m, ef_construct가 너무 낮아 인덱스 자체 품질이 낮을 수 있음

이 단계에서 “어차피 못 찾는” 상태면 MMR은 도움이 제한적입니다.

7.2 2단계: 지연시간 예산 안에서 ef_search 내리기

  • 목표 P95 지연시간을 정하고
  • ef_search를 단계적으로 내리면서 Recall@k 하락 곡선을 측정

여기서 얻는 것은 “우리 데이터에서 ef_search가 128이면 충분하다” 같은 운영 가능한 결론입니다.

7.3 3단계: 후보 풀 확대 후 MMR 적용

  • Qdrant top_k를 늘려 후보 풀을 만들고(예: 40)
  • MMR로 최종 컨텍스트를 줄입니다(예: 12)

중복률을 같이 봐야 합니다.

  • 유니크 문서 수가 늘면서 Recall@k가 유지되면 성공
  • 유니크 문서 수는 늘었는데 정답률이 떨어지면 lambda_mult를 올리거나 후보 풀 품질을 올려야 합니다

8) 자주 겪는 함정과 해결 체크리스트

8.1 top_k만 올리고 좋아졌다고 착각

top_k를 올리면 정답이 포함될 확률은 오르지만, LLM 컨텍스트 윈도우를 낭비해 답변 품질이 오히려 떨어질 수 있습니다.

  • 해결: Qdrant는 top_k를 크게, LLM에 넣는 final_k는 작게(MMR/리랭킹으로 축소)

8.2 같은 문서의 이웃 청크가 상위를 독점

  • 해결:
    • MMR 적용
    • 또는 “문서 단위 cap”을 두고 동일 doc_id에서 최대 N개만 허용

8.3 필터 조건이 인덱스 품질 문제처럼 보임

권한 필터, 날짜 필터가 강하면 검색 후보가 급격히 줄어 HNSW 튜닝 효과가 작아집니다.

  • 해결: 필터가 강한 쿼리는 ef_search를 올리기보다, 필터 후 후보 풀이 너무 작아지는지부터 확인

8.4 데이터가 커진 뒤 품질이 하락

  • 원인 후보:

    • 인덱스 파라미터가 데이터 규모에 비해 보수적
    • 임베딩 분포가 바뀜(모델 교체, 전처리 변경)
  • 해결:

    • m, ef_construct 재평가 후 재인덱싱 고려
    • 품질 회귀 테스트(고정 질문 세트) 자동화

임베딩/인덱스가 커지면서 운영 비용이 폭주하는 패턴은 아래 글의 체크리스트도 참고할 만합니다.


9) 권장 기본값 프리셋(출발점)

데이터와 SLO가 다르므로 정답은 없지만, “처음 세팅”으로는 아래 조합이 무난합니다.

  • Qdrant HNSW
    • m: 16 또는 32
    • ef_construct: 128 또는 256
    • 운영 ef_search: 64에서 시작해 128, 256으로 단계 테스트
  • 검색
    • Qdrant 후보 top_k: 30에서 50 사이
    • 최종 컨텍스트 final_k: 8에서 15 사이
  • MMR
    • lambda_mult: 0.7 시작

그리고 반드시 “고정 질문 세트”로 회귀 테스트를 돌려야 합니다. HNSW는 근사 탐색이기 때문에, 데이터가 바뀌거나 파라미터가 바뀌면 특정 질문군에서만 품질이 조용히 무너질 수 있습니다.


10) 마무리: HNSW는 재현율, MMR은 커버리지

정리하면 다음 한 줄로 귀결됩니다.

  • HNSW 튜닝은 정답이 후보에 들어오게 만드는 작업(재현율)
  • MMR 튜닝은 후보 중에서 다양한 근거를 고르는 작업(커버리지)

RAG 품질이 안 나올 때는 LLM을 탓하기 전에,

  1. ef_search를 충분히 올렸을 때도 정답이 안 나오면 인덱스/임베딩/청킹을 의심하고
  2. 정답은 나오는데 컨텍스트가 중복되면 MMR과 문서 단위 cap을 적용하고
  3. 그 다음에야 리랭커와 프롬프트 최적화로 넘어가면 비용 대비 효과가 좋아집니다.

이 순서로 접근하면 Haystack+Qdrant 조합에서도 “검색이 흔들려서 답변이 흔들리는” 문제를 체계적으로 줄일 수 있습니다.