비최적화 사용 방지하기

비최적화 사용 방지하기 (Prevent Unoptimized Usage)

소요 시간: 20분 난이도: 중급(Intermediate) 결과물: GitHub Open In Colab

대량 업로드나 구성 변경 후에는 Qdrant 컬렉션의 검색 지연 시간이 한동안 더 높아질 수 있어요. 진행 중인 최적화가 인덱싱되지 않은 세그먼트를 만들고, 그 세그먼트에 걸린 쿼리는 결과를 돌려주려면 전체 스캔을 해야 하기 때문이에요.

출처: Qdrant 공식문서 - Prevent Unoptimized Usage

서로 다른 경로의 두 가지 해결책

Qdrant v1.17까지는 해결책이 읽기 경로(read path)에 있었어요. indexed_only는 완전히 최적화된 세그먼트만 검색하고, 인덱싱되지 않은 세그먼트는 건너뛰라고 Qdrant에 지시하는 검색 파라미터예요.

여기엔 트레이드오프가 있어요. 포인트가 깜빡일 수 있죠. 포인트가 작은 세그먼트에 잠깐 나타났다가, 그 세그먼트가 인덱싱 임계값을 넘어 최적화를 시작하면 최적화가 끝날 때까지 결과에서 사라질 수 있어요.

Qdrant v1.17.1은 쓰기 경로(write path)에 두 번째 해결책을 추가했어요. 실험적인 prevent_unoptimized 최적화 설정이에요. 세그먼트가 최적화를 시작하면, 세그먼트에 새로 추가된 포인트는 세그먼트가 최적화를 끝내고 검색 가능해질 때까지 지연(deferred) 상태로 머물러요. Qdrant는 지연된 포인트를 영속 저장소에 계속 쓰기 때문에 데이터가 유실되지는 않아요. 다만 준비되기 전까지 검색에서 잡아 두는 것뿐이에요.

이 튜토리얼에서는 prevent_unoptimized를 켜는 방법, 업로드와 결합하는 방법, 최적화 진행 상황을 모니터링하는 방법, 그리고 recall(재현율) 측면에서 드는 비용을 보여드릴게요. 함께 제공되는 노트북은 같은 절차를 실제 클러스터에서 실행해요.

사전 준비 (Prerequisites)

Qdrant 클라이언트와, 이 튜토리얼에서 쓰는 데이터셋을 내려받고 처리할 huggingface-hubpolars를 설치하세요.

pip install -q qdrant-client huggingface-hub polars

무료 티어 Qdrant Cloud 클러스터를 만들고, 기본값보다 긴 타임아웃으로 비동기(async) 클라이언트를 생성하세요.

from qdrant_client import AsyncQdrantClient
from getpass import getpass

client = AsyncQdrantClient(
    url=getpass("Qdrant URL:"),
    api_key=getpass("Qdrant API key:"),
    timeout=60,
    prefer_grpc=True,
)

검색과 최적화 모니터링을 같은 클라이언트에서 동시에 실행하기 때문에, async 클라이언트가 이 호출들이 서로 막히지 않게 해 줘요. REST보다 gRPC를 선호하는 것도 대량 업로드 중 처리량(throughput)에 도움이 돼요.

두 개의 컬렉션 만들기

768차원 벡터의 컬렉션 두 개를 만드는데, 하나는 prevent_unoptimized가 켜져 있고 하나는 꺼져 있어요. 둘 사이의 쿼리 지연 시간과 최적화 시간을 비교하기 위해서예요.

from qdrant_client import models

async def create_collection(collection_name: str, prevent_unoptimized: bool = True) -> None:
    await client.create_collection(
        collection_name=collection_name,
        vectors_config=models.VectorParams(size=768, distance=models.Distance.COSINE),
        optimizers_config=models.OptimizersConfigDiff(prevent_unoptimized=prevent_unoptimized),
    )

await create_collection("prevent-unoptimized")
await create_collection("allow-unoptimized", prevent_unoptimized=False)

데이터셋 다운로드와 업로드

Hugging Face에서 ashraq/cohere-wiki-embedding-100k를 내려받아요. 미리 임베딩된 Wikipedia 구절 100,000개고, polars로 불러옵니다.

from huggingface_hub import snapshot_download
import polars as pl

data_path = snapshot_download(
    repo_id="ashraq/cohere-wiki-embedding-100k",
    repo_type="dataset",
    allow_patterns=["data/train-*-of-*.parquet"],
)
data = pl.read_parquet(source=f"{data_path}/data/train-*-of-*.parquet", columns=["emb"])

임베딩을 각 컬렉션에 1,000 포인트 단위 배치로 업로드해요.

import uuid

async def upload_points(collection_name: str, df: pl.DataFrame) -> None:
    for batch in df.iter_slices(1000):
        points = [
            models.PointStruct(id=str(uuid.uuid4()), vector=row["emb"])
            for row in batch.iter_rows(named=True)
        ]
        await client.upsert(collection_name=collection_name, points=points, wait=False)

prevent_unoptimized를 켜고 업로드할 때는 wait=False로 설정하세요. wait=True로 하면 각 upsert 호출이 자기 포인트가 보일 때까지, 즉 포인트가 속한 세그먼트가 최적화를 끝낼 때까지 블록돼요. 대량 업로드에서는 이게 루프 전체를 멈춰 세우고 클라이언트를 타임아웃시킬 수 있어요. Rust나 Go SDK, REST API에는 해당하지 않아요. 이들은 이미 기본값이 wait=False거든요. 전체 설명은 Effect on wait=true에서 볼 수 있어요.

최적화 진행 상황 모니터링

지연된 포인트 개수를 보려면 get_collection을 폴링하고, 실행 중이거나 대기 중인 최적화 작업을 보려면 get_optimizations를 폴링해서, 두 큐가 모두 비워질 때까지 반복해요.

import asyncio
import time

async def get_optimizations_progress(signal: asyncio.Event, collection_name: str) -> float:
    start = time.perf_counter()
    while True:
        optimizations, info = await asyncio.gather(
            client.get_optimizations(collection_name=collection_name, with_="completed,queued,idle_segments"),
            client.get_collection(collection_name=collection_name),
        )
        deferred = info.update_queue.deferred_points if info.update_queue else 0
        print(f"Deferred points: {deferred or 0}, running: {len(optimizations.running)}, queued: {len(optimizations.queued or [])}")
        if len(optimizations.running) == 0 and len(optimizations.queued or []) == 0:
            signal.set()
            break
        await asyncio.sleep(0.5)
    return time.perf_counter() - start

이 정보는 클라이언트 없이도, /collections/{collection_name}/optimizationsGET 요청을 하거나 /collections/{collection_name}을 조회해서 .update_queue.deferred_points를 읽으면 얻을 수 있어요. 또한 텔레메트리와 메트릭으로도 흘러 들어가서, 같은 숫자로 대시보드나 알림을 만들 수 있어요.

최적화 중에 검색 쿼리 보내기

최적화가 실행되는 동안 1,000개의 샘플 벡터로 두 컬렉션을 반복적으로 쿼리하고 각 쿼리의 지연 시간을 기록하다가, 최적화 신호가 울리면 멈춰요.

queries = data.sample(1000)["emb"].to_list()

async def query(signal: asyncio.Event, collection_name: str, queries: list) -> tuple[list[float], float]:
    start = time.perf_counter()
    latencies = []
    while True:
        for q in queries:
            q_start = time.perf_counter()
            await client.query_points(collection_name=collection_name, query=q)
            latencies.append(time.perf_counter() - q_start)
        if signal.is_set():
            break
    return latencies, time.perf_counter() - start

두 컬렉션 모두에 업로드를 실행하고, 각각에 대해 쿼리 루프와 최적화 모니터를 동시에 돌려서, 최적화가 진행되는 전체 시간 동안 쿼리 지연을 측정해요.

async def query_and_optimize(collection_name: str, queries: list) -> dict:
    signal = asyncio.Event()
    opt_time, (latencies, query_time) = await asyncio.gather(
        get_optimizations_progress(signal, collection_name),
        query(signal, collection_name, queries),
    )
    return {"total_optimization_time": opt_time, "total_query_time": query_time, "query_latencies": latencies}

await asyncio.gather(
    upload_points("prevent-unoptimized", data),
    upload_points("allow-unoptimized", data),
)
stats_prevent, stats_unopt = await asyncio.gather(
    query_and_optimize("prevent-unoptimized", queries),
    query_and_optimize("allow-unoptimized", queries),
)

왜 동시에 폴링하고 쿼리할까

query_and_optimizeget_optimizations_progressqueryasyncio.gather로 동시에 실행해요. 순차적으로 실행하는 게 아니라요. 이건 의도적인 설계이고, 운영 환경에서 실제로 일어나는 일을 그대로 반영해요. 대량 로드 후 컬렉션이 최적화 백로그를 처리하는 동안 검색이 멈추지는 않아요. 트래픽은 계속 들어오고, 그것이 같은 CPU와 I/O 자원을 인덱싱, 병합, vacuum과 경쟁하죠.

두 루프를 동시에 실행하는 건 우리가 실제로 관심 있는 효과, 즉 컬렉션이 최적화 압박을 받는 동안의 쿼리 지연 시간을 측정하게 해 줘요. get_optimizations_progress는 0.5초마다 /collections/{collection_name}/optimizations를(그리고 deferred_points를 위해 get_collection도) 폴링하고, 실행 중이거나 대기 중인 작업이 없으면 asyncio.Event를 설정해요. query 루프는 queries를 한 바퀴 돌 때마다 그 이벤트를 확인하고, 최적화가 완전히 소진된 뒤에야 멈춰요. 그래서 수집된 모든 지연 샘플은 세그먼트가 아직 작업 중이던 순간에 해당해요.

prevent_unoptimized=True에서는 이 단계에서 deferred_points를 주시하세요. 세그먼트가 최적화되는 동안 0이 아닌 값이 나오는 건 정상이에요. 최적화 중인 세그먼트에 새로 쓰인 포인트는 그 세그먼트가 준비될 때까지 검색에서 잡혀 있기 때문이에요. 이 숫자가 일시적으로 높아도 괜찮아요. 해당 최적화가 끝나면 0으로 줄어들기만 하면 되거든요.

측정 결과

Qdrant Cloud 무료 티어 클러스터의 768차원·100,000포인트 컬렉션에서, prevent_unoptimized는 총 최적화 시간을 88.1초에서 0.6초로 줄이고 쿼리 처리량과 지연 시간은 사실상 그대로 유지했어요:

설정 최적화 시간 p50 지연 p95 지연 p99 지연 처리량
prevent_unoptimized=true 0.56s 0.117s 0.187s 0.206s 7.16 qps
prevent_unoptimized=false 88.15s 0.120s 0.193s 0.209s 7.00 qps

옵티마이저는 큰 인덱싱되지 않은 세그먼트를 스캔하는 검색과 더 이상 경쟁하지 않기 때문에 150배 더 빨라지고, 그 사이 쿼리 지연은 퇴보하지 않아요.

트레이드오프

더 빠른 최적화와 안정적인 쿼리 지연이라고 하면 prevent_unoptimized가 항상 옳은 선택처럼 보일 수 있어요. 하지만 그게 전부는 아니에요. 정의상 prevent_unoptimized는 최적화가 끝나지 않은 세그먼트에 있는 포인트를 검색 결과에서 제외하거든요.

즉, 검색은 결과를 더 적게, 어쩌면 전혀 돌려주지 않을 수 있고, 먼저 업로드된 포인트로만 제한돼요. 이건 신선도(freshness) 문제이기도 해요. 최근에 쓴 데이터는 자기 세그먼트가 최적화를 끝낼 때까지 나타나지 않으니까요.

결과와 recall의 일시적인 손실은 최적화 시간이 짧은 작은 컬렉션에서는 흔히 감당할 만해요. 그런 곳에서 prevent_unoptimized는 확실한 지연 이득이에요. 최적화 시간이 긴 더 큰 컬렉션에서는 같은 설정이, 모든 세그먼트가 완전히 최적화될 때까지 사용자에게 오랫동안 부분적인 결과만 보여줄 수 있어요. 켜기 전에 컬렉션의 쓰기 볼륨과 세그먼트 크기를 저울질해 보세요.

복제(replication)된 컬렉션에서는 prevent_unoptimized가 포인트를 리플리카 간에 깜빡이게 만들 수도 있어요. 지연된 포인트는 각 리플리카에서 조금씩 다른 시점에 보이기 때문에, 같은 쿼리에 대한 연속 요청이 서로 다른 리플리카에 도달하면 포인트가 나타났다 사라졌다 다시 나타날 수 있죠. X-Qdrant-Route-Affinity 헤더로 클라이언트의 읽기를 한 리플리카에 고정하면 피할 수 있어요. 자세한 내용은 Read Affinity를 참고하세요.

더 알아보기 (Learn more)