ColPali/ColQwen으로 Qdrant 멀티벡터 문서 검색하기

ColPali/ColQwen으로 Qdrant 멀티벡터 문서 검색하기 (pdf-retrieval-at-scale)

시간: 30분 난이도: 중급 출력: GitHub Colab에서 열기

효율적인 PDF 문서 검색은 (에이전트형) 검색 증강 생성(RAG) 이나 다른 많은 검색 기반 애플리케이션에서 흔한 요구예요. 동시에 PDF 문서 검색을 구성하는 일은 추가적인 어려움 없이 되기 어려운 경우가 많아요.

> 출처: [Qdrant 공식 문서 — pdf-retrieval-at-scale](https://qdrant.tech/documentation/tutorials-search-engineering/pdf-retrieval-at-scale)

이 튜토리얼을 끝내면 VLLM(비전 대형 언어 모델)이 만든 무거운 멀티벡터 표현으로 PDF 검색을 확장하는 최적화된 방법을 알게 됩니다. 바로 시작할게요.

기존 PDF 검색의 한계

많은 전통적인 PDF 검색 솔루션은 표, 이미지, 차트 같은 시각적으로 복잡한 요소를 처리하기 위해 광학 문자 인식(OCR) 과 사용 사례별 휴리스틱에 의존해요. 이 알고리즘들은 종종 같은 도메인 안에서조차 비이식적이고, 작업에 맞게 커스터마이즈된 파싱·청킹 전략을 요구하며, 노동 집약적이고, 오류에 취약하며, 확장하기 어려워요.

ColPali와 그 후속 모델 ColQwen 같은 비전 대형 언어 모델(VLLM) 의 최근 발전이 PDF 검색의 변환을 시작했어요. 이 멀티모달 모델들은 전처리 없이 PDF 페이지를 입력으로 직접 다룹니다. 이미지로 변환할 수 있는 것은 무엇이든(그림의 스크린샷으로서의 PDF를 생각하면 돼요) 이 모델들이 효과적으로 처리할 수 있어요. 사용이 훨씬 단순함에도 VLLM은 Visual Document Retrieval (ViDoRe) Benchmark 같은 PDF 검색 벤치마크에서 최첨단 성능을 달성합니다.

VLLM이 PDF 검색을 위해 어떻게 동작하나요? (How VLLMs Work for PDF Retrieval)

ColPaliColQwen 같은 VLLM은 각 PDF 페이지에 대해 멀티벡터 표현(multivector representation) 을 생성해요. 그 표현은 벡터 데이터베이스에 저장되고 인덱싱됩니다. 검색 과정에서 모델은 (텍스트) 사용자 질의에 대한 멀티벡터 표현을 동적으로 만들고, PDF 페이지와 질의 사이의 정밀한 매칭은 late-interaction 메커니즘을 통해 이뤄져요.

VLLM 확장의 도전 과제 (Challenges of Scaling VLLMs)

VLLM이 만드는 무거운 멀티벡터 표현은 규모에서의 PDF 검색을 계산 집약적으로 만들어요. 이 모델들은 최적화 없이 쓰면 대규모 PDF 검색 작업에 비효율적입니다.

확장 뒤의 수학 (Math Behind the Scaling)

ColPali는 PDF 페이지당 1,000개 이상의 벡터를 생성하고, 후속 모델인 ColQwen은 이미지 크기에 따라 동적으로 조정되는 최대 768개 벡터를 생성해요. 보통 ColQwen은 페이지당 약 700개 벡터를 만듭니다.

그 영향을 이해하려면 벡터 데이터베이스의 흔한 인덱싱 알고리즘인 HNSW 인덱스의 구성을 생각해 봐요. 새 PDF 페이지를 인덱스에 삽입하는 데 필요한 비교 횟수를 대략 추정해 봅시다.

  • 페이지당 벡터 수: ~700 (ColQwen) 또는 ~1,000 (ColPali)
  • ef_construct: 100 (기본값)

벡터 비교 횟수의 하한 추정은 이렇습니다:

$$ 700 \times 700 \times 100 = 49 \ \text{millions} $$

이제 20,000페이지에 대한 인덱스를 만드는 데 얼마나 걸릴지 상상해 보세요!

ColPali의 경우 이 숫자는 두 배가 됩니다. 결과는 극도로 느린 인덱스 구축 시간이에요.

우리의 해법 (Our Solution)

1단계 검색(first-stage retrieval) 을 위해 PDF 페이지 표현의 벡터 수를 줄이는 것을 권장해요. 줄어든 벡터 수로 1단계 검색을 수행한 후, 원래 압축되지 않은 표현으로 검색된 부분집합을 재랭킹(rerank) 하도록 제안합니다.

벡터 감소는 VLLM이 생성한 멀티벡터 출력에 평균 풀링(mean pooling) 연산을 적용해 이룰 수 있어요. 평균 풀링은 선택된 부분그룹 내 모든 벡터의 값을 평균 내어, 여러 벡터를 하나의 대표 벡터로 응축해요. 제대로 하면 원본 페이지의 중요한 정보를 보존하면서 벡터 수를 크게 줄일 수 있어요.

VLLM은 PDF 페이지의 서로 다른 부분을 나타내는 패치(patch) 에 해당하는 벡터를 생성해요. 이 패치들은 PDF 페이지의 행과 열로 그룹화될 수 있어요.

예를 들어:

  • ColPali는 PDF 페이지를 1,024개 패치로 나눈다.
  • 이 패치 행렬에 행(또는 열)별 평균 풀링을 적용하면 페이지 표현이 32개 벡터로 줄어든다.

우리는 이 접근법을 ColPali 모델로 테스트했고, PDF 페이지 행별로 멀티벡터를 평균 풀링했어요. 결과는 이렇습니다.

  • 인덱싱 시간이 한 자릿수 더 빨라짐
  • 원본 모델과 비슷한 검색 품질

이 실험의 자세한 내용은 GitHub 저장소, ColPali 최적화 블로그 포스트, 또는 웨비나 "PDF Retrieval at Scale"을 참고하세요.

이 튜토리얼의 목표 (Goal of This Tutorial)

이 튜토리얼에서는 QdrantColPali & ColQwen2 VLLM을 사용한 확장 가능한 PDF 검색 접근법을 보여줄 거예요. 소개하는 접근법은 긴 인덱싱 시간과 느린 검색 속도라는 흔한 함정을 피하기 위해 매우 권장됩니다.

이어지는 섹션에서 성공적인 실험에서 탄생한 최적화된 검색 알고리즘을 시연해요.

평균 풀링된 벡터로 1단계 검색(First-Stage Retrieval with Mean-Pooled Vectors):

  • 평균 풀링된 벡터만 사용해 HNSW 인덱스를 구축한다.
  • 1단계 검색에 그것들을 사용한다.

원본 모델 멀티벡터로 재랭킹(Reranking with Original Model Multivectors):

  • ColPali 또는 ColQwen2의 원본 멀티벡터를 사용해 1단계에서 검색된 결과를 재랭킹한다.

설정 (Setup)

필요한 라이브러리 설치 및 임포트

# pip install colpali_engine>=0.3.1
from colpali_engine.models import ColPali, ColPaliProcessor
# pip install qdrant-client>=1.12.0
from qdrant_client import QdrantClient, models

이 실험을 실행하기 위해 Qdrant 클러스터를 사용해요. 막 시작했다면 테스트·탐색용으로 무료 티어 클러스터를 설정할 수 있어요. "How to Create a Free-Tier Qdrant Cluster" 문서의 지침을 따르세요.

client = QdrantClient(url=<YOUR CLUSTER URL>, api_key=<YOUR API KEY>)

ColPali 모델과 입력 프로세서를 다운로드해요. 자신의 설정에 맞는 백엔드를 선택하세요.

colpali_model = ColPali.from_pretrained(
    "vidore/colpali-v1.3",
    torch_dtype=torch.bfloat16,
    device_map="mps",  # GPU는 "cuda:0", CPU는 "cpu", Apple Silicon은 "mps"
).eval()
colpali_processor = ColPaliProcessor.from_pretrained("vidore/colpali-v1.3")

ColQwen 모델에 대해

from colpali_engine.models import ColQwen2, ColQwen2Processor
colqwen_model = ColQwen2.from_pretrained(
    "vidore/colqwen2-v0.1",
    torch_dtype=torch.bfloat16,
    device_map="mps",  # GPU는 "cuda:0", CPU는 "cpu", Apple Silicon은 "mps"
).eval()
colqwen_processor = ColQwen2Processor.from_pretrained("vidore/colqwen2-v0.1")

Qdrant 컬렉션 생성 (Create Qdrant Collections)

이제 Qdrant에 ColPali 또는 ColQwen이 생성한 PDF 페이지의 멀티벡터 표현을 저장할 컬렉션을 만들 수 있어요.

컬렉션에는 PDF 페이지의 행·열별 평균 풀링된 표현과 원본 멀티벡터 표현이 모두 포함됩니다.

client.create_collection(
    collection_name=collection_name,
    vectors_config={
        "original": models.VectorParams(
            # HNSW 끄기
            size=128,
            distance=models.Distance.COSINE,
            multivector_config=models.MultiVectorConfig(
                comparator=models.MultiVectorComparator.MAX_SIM
            ),
            hnsw_config=models.HnswConfigDiff(m=0)  # HNSW 끄기
        ),
        "mean_pooling_columns": models.VectorParams(
            size=128,
            distance=models.Distance.COSINE,
            multivector_config=models.MultiVectorConfig(
                comparator=models.MultiVectorComparator.MAX_SIM
            )
        ),
        "mean_pooling_rows": models.VectorParams(
            size=128,
            distance=models.Distance.COSINE,
            multivector_config=models.MultiVectorConfig(
                comparator=models.MultiVectorComparator.MAX_SIM
            )
        )
    }
)

데이터셋 선택 (Choose a dataset)

이 튜토리얼에서는 Daniel van Strien의 UFO Dataset을 사용할 거예요. Hugging Face에서 제공되며 바로 다운로드할 수 있습니다.

from datasets import load_dataset
ufo_dataset = "davanstrien/ufo-ColPali"
dataset = load_dataset(ufo_dataset, split="train")

각 PDF 페이지(즉 이미지)의 멀티벡터 표현과 평균 풀링 버전을 배치로 생성하는 함수를 사용할 거예요. 완전한 이해를 위해 ColPaliColQwen의 다음 특성들을 고려하는 게 중요해요.

  • ColPali: 이론적으로 ColPali는 PDF 페이지당 1,024개 벡터를 생성하도록 설계됐지만 실제로는 1,030개 벡터를 만들어요. 이 차이는 ColPali의 전처리기가 각 입력에 <bos>Describe the image. 텍스트를 추가하기 때문이에요. 이 추가 텍스트가 멀티벡터 6개를 더 생성합니다.
  • ColQwen: ColQwen은 PDF 페이지의 크기에 따라 "행과 열"의 패치 수를 동적으로 결정해요. 결과적으로 멀티벡터 수는 입력마다 달라질 수 있어요. ColQwen 전처리기는 <|im_start|>user<|vision_start|>를 앞에 붙이고 <|vision_end|>Describe the image.<|im_end|><|endoftext|>를 뒤에 붙입니다.

get_patches 함수는 ColPali/ColQwen2 모델이 PDF 페이지를 나눌 x_patches(행)와 y_patches(열)의 수를 얻기 위한 것입니다. ColPali의 경우 숫자는 항상 32×32가 되고, ColQwen은 PDF 페이지 크기에 따라 동적으로 정의해요.

x_patches, y_patches = model_processor.get_n_patches(
    image_size, patch_size=model.patch_size
)

ColQwen 모델에 대해

model_processor.get_n_patches(
    image_size,
    patch_size=model.patch_size,
    spatial_merge_size=model.spatial_merge_size
)

우리는 접두·접미 멀티벡터를 보존하기로 선택했어요. 우리의 풀링 연산은 모델이 결정한 행·열 수(ColPali는 정적 32×32, ColQwen은 동적 X×Y)에 따라 이미지 토큰을 나타내는 멀티벡터를 압축해요. 함수는 모델이 생성한 추가 멀티벡터를 보존하고 풀링된 표현에 다시 통합합니다.

ColPali 모델에 대한 단순화된 풀링 버전:

(전체 버전 — ColQwen에도 적용 가능 — 은 튜토리얼 노트북에서 확인하세요)

processed_images = model_processor.process_images(image_batch)
# 이미지 임베딩 모양 (batch_size, 1030, 128)
image_embeddings = model(**processed_images)
# (1030, 128)
image_embedding = image_embeddings[0]
# 배치의 첫 번째 요소 취하기
# 이제 이미지 토큰에 해당하는 벡터를 식별해야 한다.
# 특별한 `image_token_id`에 해당하는 토큰을 선택하면 된다.
# (1030, ) - boolean mask (배치의 첫 번째 요소에 대해), 이미지 토큰이면 True
mask = processed_images.input_ids[0] == model_processor.image_token_id
# 편의상 이제 이미지 토큰만 선택한다.
# 그리고 (x_patches, y_patches, dim)으로 리쉐이프
# (x_patches, y_patches, 128)
image_patch_embeddings = image_embedding[mask].view(x_patches, y_patches, model.dim)
# 이제 행과 열별 평균 풀링을 적용할 수 있다.
# (x_patches, 128)
pooled_by_rows = image_patch_embeddings.mean(dim=0)
# (y_patches, 128)
pooled_by_columns = image_patch_embeddings.mean(dim=1)
# [선택] 풀링된 표현에 특수 토큰을 연결할 수도 있다.
# ColPali의 경우 postfix만 있다.
# (x_patches + 6, 128)
pooled_by_rows = torch.cat([pooled_by_rows, image_embedding[~mask]])
# (y_patches + 6, 128)
pooled_by_columns = torch.cat([pooled_by_columns, image_embedding[~mask]])

Qdrant에 업로드 (Upload to Qdrant)

업로드 과정은 단순해요. 주의할 것은 ColPali와 ColQwen2 모델의 계산 비용뿐이에요. 저리소스 환경에서는 임베딩과 평균 풀링에 더 작은 배치 크기를 사용하는 것이 권장됩니다.

업로드 코드의 전체 버전은 튜토리얼 노트북에서 확인할 수 있어요.

PDF 질의하기 (Querying PDFs)

PDF 문서를 인덱싱한 뒤, 두 단계 검색 접근법으로 질의할 수 있어요.

query = "Lee Harvey Oswald's involvement in the JFK assassination"
processed_queries = model_processor.process_queries([query]).to(model.device)
# 결과 질의 임베딩은 (22, 128) 크기의 텐서
query_embedding = model(**processed_queries)[0]

이제 VLLM이 만든 멀티벡터로 두 단계 검색을 수행하는 함수를 설계해 봅시다.

  • 1단계: 압축된 멀티벡터 표현과 HNSW 인덱스로 결과를 프리페치(prefetch)한다.
  • 2단계: 원본 멀티벡터 표현으로 프리페치된 결과를 재랭킹한다.

결합된 평균 풀링 표현을 사용해 1단계 검색을 위한 컬렉션을 질의해 봅시다.

# 반환할 최종 결과 수
search_limit = 10
# 재랭킹을 위해 프리페치할 결과 수
prefetch_limit = 100

response = client.query_points(
    collection_name=collection_name,
    query=query_embedding,
    prefetch=[
        models.Prefetch(
            query=query_embedding,
            limit=prefetch_limit,
            using="mean_pooling_columns"
        ),
        models.Prefetch(
            query=query_embedding,
            limit=prefetch_limit,
            using="mean_pooling_rows"
        ),
    ],
    limit=search_limit,
    with_payload=True,
    with_vector=False,
    using="original"
)

그리고 질의 *"Lee Harvey Oswald's involvement in the JFK assassination"*에 대한 상위 검색 결과를 확인해 봅시다.

dataset[response.points[0].payload['index']]['image']

결론 (Conclusion)

이 튜토리얼에서는 ColPaliColQwen2 같은 무거운 멀티벡터 표현을 만드는 VLLM으로 규모에서의 PDF 검색을 위한 Qdrant 최적화 접근법을 시연했어요.

이런 최적화가 없으면 검색 시스템의 성능은 특히 데이터셋 크기가 커질수록 인덱싱 시간과 질의 지연 시간 모두에서 심각하게 저하될 수 있어요.

효율적이고 확장 가능한 PDF 검색을 보장하려면 이 접근법을 워크플로우에 강력히 권장합니다. 검색 과정을 최적화하지 않으면 받아들일 수 없을 정도로 느린 성능이 나타나 시스템의 사용성을 저해할 수 있어요.

오늘부터 PDF 검색을 확장해 보세요!

더 알아보기 (Learn more)