Qdrant 대규모 검색

이번 글에서는 실제 데이터셋 LAION-400M을 예시로, 대용량 데이터를 비용 효율적으로 업로드·인덱싱·검색하는 방법을 설명해 볼게요.

이 튜토리얼의 목표는 대규모 데이터셋을 인덱싱하고 검색하는 데 최소한으로 필요한 리소스가 얼마인지 보여주면서도, 합리적인 검색 지연 시간과 정확도를 유지하는 데 있어요.

관련 코드는 모두 GitHub 저장소에서 확인할 수 있어요. 이 튜토리얼에서 추천하는 Qdrant 버전은 v1.13.5 이상이에요.

시간: 2일 난이도: 고급

데이터셋 (Dataset)

우리가 사용할 데이터셋은 LAION-400M으로, Common Crawl 데이터셋에서 추출한 이미지로부터 얻은 약 4억 개의 벡터로 구성돼 있어요. 각 벡터는 512차원이며 CLIP 모델로 생성됐어요.

각 벡터에는 url, caption, LICENSE 같은 여러 메타데이터 필드가 연결돼 있어요.

전체 페이로드 크기는 약 200GB이고, 벡터는 400GB예요.

데이터셋은 이미지 자체를 저장하지 않고, 이미지 원본의 URL만 담고 있어요. 글을 작성하는 시점 기준으로 일부 URL은 이미 접근이 불가능해졌어요.

데이터셋은 각각 약 100만 개의 벡터를 담은 409개의 청크(chunk) 로 제공돼요. 우리는 청크를 하나씩 업로드하는 다음 python 스크립트를 사용할 거예요.

하드웨어 (Hardware)

몇 차례의 초기 실험 끝에, 이 작업에 최적인 최소 하드웨어 구성을 찾았어요.

  • 8 CPU 코어
  • 64Gb RAM
  • 650Gb 디스크 공간
하드웨어 구성

이 구성이면 단일 사용자 모드로 데이터셋을 인덱싱하고 탐색하기에 충분해요. 대화형 그래프를 만들고 대시보드를 탐색하기에도 지연 시간이 합리적이에요.

물론 프로덕션급 구성이라면 CPU 코어와 RAM이 더 필요할 수 있어요. 이 실험에서는 클라이언트와 서버를 같은 리전에서 실행하도록 높은 네트워크 대역폭을 확보하는 것이 중요해요.

업로드와 인덱싱 (Uploading and Indexing)

데이터셋 청크를 하나씩 업로드하기 위해 다음 python 스크립트를 사용할 거예요.

export QDRANT_URL="https://xxxx-xxxx.xxxx.cloud.qdrant.io"
export QDRANT_API_KEY="xxxx-xxxx-xxxx-xxxx"

python upload.py

이 스크립트는 LAION 데이터셋의 청크를 하나씩 다운로드해 Qdrant에 업로드해요. 중간 데이터를 디스크에 저장하지 않으므로, 클라이언트 쪽에 많은 디스크 공간이 필요하지 않아요.

우리가 사용한 컬렉션 구성은 다음과 같아요.

client.create_collection(
        QDRANT_COLLECTION_NAME,
        vectors_config=models.VectorParams(
            size=512, # CLIP 모델 출력 크기
            distance=models.Distance.COSINE, # CLIP 모델은 cosine 거리 사용
            datatype=models.Datatype.FLOAT16, # float은 16비트면 충분, 아니면 디스크 사용량이 800Gb 대신 400Gb
            # `on_disk`는 더 이상 사용되지 않아요. 1.19 버전 이상에서는 `memory`를 사용하세요.
            on_disk=True # 원본 벡터를 RAM에 둘 필요 없어요
        ),
        # CLIP 벡터는 기본적으로 binary quantization과 잘 맞지 않지만,
        # 쿼리 시 oversampling으로 더 정확한 결과를 얻을 수 있어요
        quantization_config=models.BinaryQuantization(
            binary=models.BinaryQuantizationConfig(
                # `always_ram`은 더 이상 사용되지 않아요. 1.19 버전 이상에서는 `memory`를 사용하세요.
                always_ram=True,
            )
        ),
        optimizers_config=models.OptimizersConfigDiff(
            # 세그먼트가 클수록 검색이 faster
            # 하지만 인덱싱은 더 느릴 수 있어요
            max_segment_size=5_000_000,
        ),
        # M 값이 클수록 정확도가 높아지지만,
        # 우리 경우에는 메모리 사용량이 더 중요해요
        # M=6 + oversampling으로도 합리적인 정확도를 얻을 수 있어요
        hnsw_config=models.HnswConfigDiff(
            m=6, # 메모리 사용량을 줄이기 위해 M을 낮춰요
            # `on_disk`는 더 이상 사용되지 않아요. 1.19 버전 이상에서는 `memory`를 사용하세요.
            on_disk=False
        ),
    )

여기서 주목할 점이 몇 가지 있어요.

  • 벡터에 FLOAT16 데이터 타입을 사용해서 FLOAT32 대비 절반 크기로 저장할 수 있어요. 이 데이터셋에서는 정확도 손실이 크지 않아요.
  • always_ram=TrueBinaryQuantization을 사용해 쿼리 시 oversampling을 가능하게 해요. 512차원 CLIP 벡터는 기본적으로 binary quantization과 잘 맞지 않지만, 덕분에 정확하고 리소스 효율적인 검색을 할 수 있어요.
  • 메모리 사용량을 줄이기 위해 m=6HnswConfig를 사용해요. 메모리 사용량은 다음 섹션에서 자세히 살펴볼게요.

이 구성의 목표는 검색의 prefetch 단계가 디스크에서 데이터를 불러올 필요가 없도록 하고, 벡터와 벡터 인덱스의 최소 버전이 항상 RAM에 있게 하는 거예요. 검색의 두 번째 단계에서는 디스크에서 데이터를 얼마나 불러올 수 있는지 명시적으로 결정할 수 있어요.

우리 실험에서는 업로드 프로세스가 초당 5000 포인트 속도로 진행됐어요. 인덱싱 프로세스는 업로드와 병렬로 진행됐고, 대략 초당 4000 포인트 속도로 수행됐어요.

업로드 및 인덱싱 프로세스

메모리 사용량 (Memory Usage)

업로드와 인덱싱이 끝나면 Qdrant 서버의 메모리 사용량을 자세히 살펴볼게요.

메모리 사용량

크게 보면 메모리 사용량은 세 가지 구성 요소로 나뉘어요.

  • 시스템 메모리 - 8.34Gb - 내부 시스템과 OS용으로 예약된 메모리로, 데이터셋 크기와 무관해요.
  • 데이터 메모리 - 39.27Gb - qdrant 프로세스의 resident 메모리로, evict(퇴출)할 수 없고, 한도를 넘으면 qdrant 프로세스가 크래시돼요.
  • 캐시 메모리 - 14.54Gb - qdrant가 사용하는 디스크 캐시로, 빠른 검색에 필요하지만 필요하면 evict할 수 있어요.

우리에게 가장 관심 있는 건 Data와 Cache 메모리예요. 이 구성 요소에 정확히 무엇이 저장되는지 살펴볼게요.

우리 시나리오에서 Qdrant는 다음 구성 요소를 저장하는 데 메모리를 사용해요.

  • 벡터 저장
  • 벡터 인덱스 저장
  • 포인트의 ID와 버전 정보 저장

참고로, payload 인덱스는 이 튜토리얼의 범위 밖이에요. 컬렉션에 payload 인덱스를 사용한다면 그에 맞춰 추정치를 조정해야 할 수 있어요.

벡터 크기 (Size of vectors)

우리 시나리오에서는 양자화된 벡터만 RAM에 저장하므로, 필요한 크기를 비교적 쉽게 계산할 수 있어요.

400_000_000 * 512d / 8 bits / 1024 (Kb) / 1024 (Mb) / 1024 (Gb) = 23.84Gb

벡터 인덱스 크기 (Size of vector index)

벡터 인덱스는 단순한 행렬이 아니라서 조금 더 복잡해요.

내부적으로는 그래프의 연결 목록으로 저장되는데, 각 연결은 4바이트 정수예요.

연결 수는 HNSW 인덱스의 M 매개변수로 결정돼요. 우리 경우에는 상위 레벨에서 6, 레벨 0에서는 2 x M이에요.

이를 바탕으로 추정하면 다음과 같아요.

400_000_000 * (6 * 2) * 4 bytes / 1024 (Kb) / 1024 (Mb) / 1024 (Gb) = 17.881Gb

실제 인덱스 크기는 Qdrant v1.13.0에 구현된 압축 덕분에 조금 더 작지만, 여전히 좋은 추정치예요.

Qdrant의 HNSW 인덱스는 mmap으로 저장되어 필요하면 RAM에서 evict할 수 있어요. 그래서 HNSW의 메모리 소비는 Cache memory 범주에 속해요.

ID와 버전의 크기 (Size of IDs and versions)

Qdrant는 포인트마다 ID, 버전 같은 추가 정보를 저장해야 해요. 이 정보는 매 요청마다 필요하므로 빠른 접근을 위해 RAM에 유지하는 것이 매우 중요해요.

이 정보에 필요한 메모리 양을 이해하기 위해 Qdrant 내부를 살펴볼게요.


// 이는 IdTracker 구조체의 단순화된 버전이에요
// 모든 최적화와 세부 사항은 생략했지만,
// 메모리 사용량을 잘 추정할 수 있어요
IdTracker {
    // 내부 id와 버전(u64)의 매핑, 4바이트로 압축됨
    // 세그먼트 간 버전 관리와 충돌 해결에 필요
    internal_to_version, // 400M x 4 = 1.5Gb

    // 외부 id와 내부 id의 매핑, 포인트당 4바이트
    // 세그먼트 내부에서 검색 후 원본 포인트 ID를 결정하는 데 필요
    internal_to_external: Vec<u128>, // 400M x 16 = 6.4Gb

    // 외부 id와 내부 id의 매핑. 숫자 id는 8바이트,
    // UUID는 16바이트로 저장
    // 세그먼트 내부의 순차 포인트 ID를 결정하는 데 필요
    external_to_internal: Vec<u64, u32>, // 400M x (8 + 4) = 4.5Gb
}

v1.13.5에서 IdTracker의 메모리 사용량을 약 2배 줄이는 큰 최적화를 도입했어요. 그래서 우리 경우 IdTracker의 총 메모리 사용량은 약 12.4Gb예요.

따라서 우리 경우 Qdrant 서버의 총 예상 RAM 사용량은 약 23.84Gb + 17.881Gb + 12.4Gb = 54.121Gb이고, 이는 관측된 실제 메모리 사용량 39.27Gb + 14.54Gb = 53.81Gb와 매우 가까워요.

추정에 몇 가지 단순화를 적용하긴 했지만, Qdrant 서버의 메모리 사용량을 이해하기엔 충분해요.

데이터셋을 업로드하고 인덱싱했으니, 이제 유사 벡터 검색을 시작할 수 있어요.

Web-UI에서 데이터셋을 탐색하면서 시작할 수 있어요. 표로 된 숫자뿐 아니라 검색 성능에 대한 직관을 얻을 수 있어요.

Web-UI 곰 이미지
Web-UI 유사 곰 이미지

Web-UI 기본 요청은 oversampling을 사용하지 않지만, 관측 가능한 결과만으로도 이미지 간 유사성을 확인하기에 충분해요.

Ground truth 데이터 (Ground truth data)

그래도 검색 성능을 더 정확하게 평가하려면 검색 결과를 ground truth와 비교해야 해요. 안타깝게도 LAION 데이터셋에는 사용 가능한 ground truth가 없어서, 직접 생성해야 했어요.

그러려면 데이터셋의 각 벡터에 대해 전체 스캔 검색을 수행하고 결과를 별도 파일에 저장해야 해요. 이 과정은 매우 시간이 오래 걸리고 많은 리소스가 필요해서, 쿼리 수를 100개로 제한했어요. 바로 사용할 수 있는 ground truth 파일과 이를 생성하는 스크립트를 제공해요 (512Gb RAM 머신과 약 20시간의 실행 시간 필요).

우리 ground truth 파일은 쿼리 100개를 담고 있고, 각각 50개의 결과가 있어요. 데이터셋 자체의 첫 100개 벡터가 쿼리 생성에 사용됐어요.

참고로, 이 데이터셋에는 정확한 중복이 상당히 많아서 실행할 때마다 결과 순서가 다를 수 있어요.

검색 쿼리 (Search Query)

oversampling 양을 정확히 제어하기 위해 다음 검색 쿼리를 사용할 거예요.


limit = 50
rescore_limit = 1000 # oversampling 계수는 20

query = vectors[query_id] # 기존 벡터 중 하나

response = client.query_points(
        collection_name=QDRANT_COLLECTION_NAME,
        query=query,
        limit=limit,
        # 디스크로 이동
        search_params=models.SearchParams(
            quantization=models.QuantizationSearchParams(
                rescore=True,
            ),
        ),
        # Prefetch는 RAM 내 데이터만 사용하므로,
        # 대량의 데이터를 쿼리해도 빠름
        prefetch=models.Prefetch(
            query=query,
            limit=rescore_limit,
            params=models.SearchParams(
                quantization=models.QuantizationSearchParams(
                    # Prefetch에서 rescoring을 피해요
                    # 두 번째 단계에서 명시적으로 수행해야 해요
                    rescore=False,
                ),
            )
        )
    )

보시다시피 이 쿼리는 두 단계로 구성돼 있어요.

  • 첫 번째 단계는 prefetch로, RAM 내 데이터만 사용해 수행돼요. 매우 빠르고 많은 후보를 얻을 수 있어요.
  • 두 번째 단계는 rescore로, 디스크에 저장된 전체 크기 벡터로 수행돼요.

2단계 검색을 사용하면 디스크에서 불러오는 데이터 양을 정확히 제어할 수 있고, 검색 속도와 정확도의 균형을 보장할 수 있어요.

검색 프로세스의 전체 코드는 eval.py에서 확인할 수 있어요.

성능 조정 (Performance tweak)

이 데이터셋에 유용했던 중요한 성능 조정은 Qdrant에서 Async IO를 활성화하는 거였어요.

기본적으로 Qdrant는 동기식 IO를 사용하는데, 인메모리 데이터셋에는 좋지만 디스크에서 많은 데이터를 읽을 때는 병목이 될 수 있어요.

Async IO(io_uring으로 구현)는 디스크에 병렬 요청을 보내 디스크 대역폭을 포화시킬 수 있어요.

원본 벡터로 대규모 rescoring을 수행할 때 정확히 필요한 기능이에요. 벡터를 하나씩 읽으며 1000번 디스크 응답을 기다리는 대신, 디스크에 1000개의 요청을 보내고 모든 요청이 완료될 때까지 기다릴 수 있어요. 이를 통해 디스크 대역폭을 포화시키고 더 빠른 결과를 얻을 수 있어요.

Qdrant에서 Async IO를 활성화하려면 환경 변수를 설정해야 해요.

QDRANT__STORAGE__PERFORMANCE__ASYNC_SCORER=true

또는 설정 파일에서 매개변수를 설정해요.

storage:
  performance:
    async_scorer: true

Qdrant Managed Cloud에서는 클러스터 Configuration 탭의 Advanced optimizations 섹션에서 Async IO를 활성화할 수 있어요.

Cloud의 Async IO 구성

Qdrant v1.19 기준으로, storage.performance.io_uring은 더 넓은 대안이에요. auto로 설정하면 벡터 rescoring뿐 아니라 디스크에 있을 때 벡터 저장소와 payload 저장소에도 async I/O를 적용해요. 자세한 내용은 Async I/O를 참고하세요.

검색 요청 실행 (Running search requests)

모든 준비가 끝나면 검색 요청을 실행하고 결과를 평가할 수 있어요.

검색 프로세스의 전체 코드는 eval.py에서 확인할 수 있어요. 이 스크립트는 구성된 oversampling 계수로 검색 요청 100개를 실행하고 ground truth와 결과를 비교해요.

python eval.py --rescore_limit 1000

우리 요청에서 다음과 같은 결과를 얻었어요.

Rescore Limit Precision@50 요청당 시간
1000 75.2% 0.7s
5000 81.0% 2.2s

m=16을 사용한 추가 실험에서는 rescore_limit=1000으로 85% 정밀도를 달성할 수 있었지만, 메모리가 조금 더 필요했어요.

검색 평가 로그

결론 (Conclusion)

이 튜토리얼에서는 Qdrant에서 대규모 데이터셋을 비용 효율적으로 업로드·인덱싱·검색하는 방법을 보여줬어요.

Binary quantization은 512차원 벡터에도 적용할 수 있어요. 쿼리 시 oversampling과 결합하면 되죠.

Qdrant는 스토리지의 각 부분이 어디에 위치할지 정확히 제어할 수 있게 해줘서, 검색 속도와 메모리 사용량 사이의 좋은 균형을 달성할 수 있어요.

잠재적 개선점 (Potential improvements)

이 실험에서는 스토리지의 어떤 부분이 메모리 사용량을 담당하는지, 그리고 이를 어떻게 제어하는지 자세히 살펴봤어요.

특히 흥미로운 부분은 벡터 간 연결 그래프를 저장하는 VectorIndex 구성 요소예요.

향후 연구에서는 HNSW를 더 디스크 친화적으로 만들어 성능 손실 없이 디스크로 오프로드할 수 있는지 조사할 예정이에요.

출처: Qdrant 공식문서 - Large-Scale Search

더 알아보기 (Learn more)