DB/Vector / / 2025. 12. 22. 19:32

[Qdrant Vector DB] 벡터 DB에서 페이징 처리 설계

1. 문제 인식 – “검색 결과가 이상해요”

Qdrant Vector DB에서 페이징을 설계하는 중 FE에서 검색 시 응답받는 totalElements가 이상하다. 라는 피드백을 받았습니다. 매번 totalElements가 다르게 응답되고 있었고, 그 문제를 해결하는 과정을 담아보았습니다.

일반적인 검색/목록 화면에서 페이징은 보통 이렇게 정리됩니다.

  • offset 기반 페이징으로 개발
    • 예: page=3&pageSize=20 → 41~60번째 결과
  • 현재 검색 조건(검색어 + 필터)에 대해
    • 총 몇 건인지(totalElements)
    • 총 몇 페이지인지(totalPages)

문제는, 우리가 사용하는 Qdrant가

  1. 벡터 검색(QueryPoints)에 대해 offset/limit 페이징은 지원하지만
  2. 해당 벡터 + 필터 조합으로 “총 몇 건인지”를 알려주는 API는 제공하지 않는다는 점입니다.

이 글에서는 실제로 어떤 제약이 있는지, 왜 이런 제약이 있는지, 그리고 설계 측면에서 어떻게 풀었는지에 대해 정리하려고 합니다.


2. Qdrant에서 제공하는 것들 정리

먼저 현재 Qdrant가 제공하는 페이징 관련 기능을 정리하면 아래와 같습니다.

2.1. 벡터 검색(QueryPoints) – offset/limit 지원

우리가 주로 사용하는 query_points API는 다음을 지원합니다.

  • 벡터 쿼리: query = nearest(vec)
  • 옵션 필터: payload 기반 필터 (filter)
  • 페이징 파라미터: limit, offset

즉, “벡터 + 필터 + offset/limit” 조합으로 페이지 데이터를 가져오는 것은 지원합니다.
우리가 실제 코드에서 쓰는 부분도 이것입니다.

Points.QueryPoints.Builder builder = Points.QueryPoints.newBuilder()
    .setCollectionName(collectionName)
    .setQuery(QueryFactory.nearest(vec)) // 벡터 검색
    .setLimit(limit)
    .setOffset(offset)
    .setWithPayload(...)
    .setParams(...)
    .setFilter(filter);                 // (필터가 있다면)

이 쪽은 동작에 문제가 없습니다.
지정한 offset/limit에 맞게 해당 페이지에 해당하는 결과는 잘 내려옵니다.


2.2. Count API – 필터 기반 개수 세기

별도로 Qdrant에는 count API가 있습니다.

  • filter + exact 파라미터만 받습니다.
  • 벡터(query)는 아예 받을 수 있는 필드가 없습니다.
  • 결과는 이 컬렉션에서 해당 필터에 맞는 포인트가 몇 개인지를 반환하는게 전부 입니다.
public long countPoints(Points.Filter filter) {
    ensureInitialized();
    try {
        return qdrantClient.countAsync(collectionName, filter, true, Duration.ofSeconds(5))
                .get(5, TimeUnit.SECONDS);
    } ...
}

정리하자면

  • countPoints(filter) → 필터 기준 전체 개수
  • searchText(vec, limit, offset, filter) → 벡터 + 필터 기준 특정 페이지 데이터

즉, Qdrant는 설계상 필터 기반 전체 개수 세기와 벡터 기반 top-K 검색 2개의 API를 서로 다른 API로 분리해 두었습니다. 카운트를 하는데 검색어(query/벡터)는 받을 수 없는거죠.


3. 요구사항과의 충돌 지점

원하는 것은 다음과 같은 형태였습니다.

  1. offset 기반 전통적인 페이징 UI
    • 예: Prev / 1 2 3 … 22 / Next
  2. 페이지 기준이 되는 검색 조건
    • 벡터(검색어 임베딩) + 여러 필터 조건
  3. 그리고…
    • 이 조건에 대한 정확한 totalElements (총 몇 건인지)
    • totalPages 계산 (ceil(totalElements / pageSize))

하지만 앞에서 본 것처럼 Qdrant는 query_points 응답에 total count를 넣어주지 않고, count API는 벡터를 모릅니다(필터만 적용).

이게 이번 이슈의 핵심입니다.


4. 그렇다면 totalElements를 못 구하는 이유는?

여기서 자연스럽게 나오는 질문이 있습니다.

“그럼 벡터 검색할 때, 그냥 total도 같이 계산해서 내려주면 안 되나?”

이 부분은 Qdrant가 사용하는 벡터 검색 방식(HNSW 기반 ANN)의 특성과 관련이 있습니다.

HNSW/ANN은 top-K 가까운 이웃을 빠르게 찾기 위한 구조입니다.

이 쿼리로 매칭되는 전체 개수를 정확히 세라”는 건 사실상 모든 후보를 끝까지 탐색하거나, 그에 준하는 연산이 필요합니다.

여기에 필터까지 섞이고, 분산/샤딩 구조까지 고려하면? 매 쿼리마다 “정확한 total”을 내려주는 것은 성능·리소스 측면에서 부담이 상당하게 되는거죠.

그래서 Qdrant 쪽 설계는 “정확한 total이 필요한 카운트는 payload 인덱스를 활용한 filter 기준 count로 분리”하고, 벡터 쿼리는 빠른 top-K 검색에 집중하기 때문에 둘을 섞어서 “벡터 + 필터에 대한 정확 total”까지 한 번에 제공하는 기능은 아직 제공하지 않는 방향으로 굳어진 상태입니다. 일반적으로 생각하는 검색처럼 "토마토"를 검색했을 때 검색 결과가 딱 312개 나오는 것 과는 다른거죠.


5. 어떻게 기준을 정할 것인가?

이제 현실적인 선택을 해야 합니다.
현재 Qdrant의 제약 안에서 할 수 있는 선택지는 크게 세 가지였습니다.

5.1. 선택지 1 – 필터 기준 total을 “근사값”으로 사용

이 경우 UI 상으로는 “이 검색 조건에 대해 총 N건”이라고 보이지만, 실제 의미는 “필터까지 만족하는 데이터가 N건이고, 그 중에서 벡터 기준으로 상위 결과를 페이지 단위로 보여주는 중”에 가깝습니다.

장점은 기존 offset 페이징 UX (페이지 번호, totalPages 등)를 유지할 수 있고, Qdrant가 제공하는 API 범위 안에서 구현 가능합니다.

단점은 “벡터까지 반영된 완전히 정확한 total”은 아니라는 점입니다.

5.2. 선택지 2 – totalElements를 과감히 포기한 UX

query_points(limit, offset) 만 사용하고 UI에서는 “총 N건 / 총 M페이지”를 아예 표시하지 않고, “다음 페이지 / 더 보기”만 제공하는 방법입니다.

다음 페이지 여부 판단은 limit = pageSize + 1로 요청 후

  • 결과가 pageSize + 1개 → 다음 페이지 있음
  • 결과가 pageSize 이하 → 마지막 페이지

이 방식은 백엔드/프론트 모두 구현이 단순하고, Qdrant 설계와도 합이 괜찮습니다. 하지만 다만 “총 몇 건이냐”를 사용자에게 보여주지 못한다는 UX상의 trade-off가 존재합니다. 하지만 필터되는 데이터는 카운트가 가능하기 때문에 무조건 total이 맞아야해! 라는 요구사항이 아니면 좋은 선택지라고 생각했습니다.

5.3. 선택지 3 – 전체를 다 가져와서 세기 (특수 케이스)

이론적으로는 limit을 매우 크게 잡고(예: 수만 건), 모든 페이지를 끝까지 돌면서 결과를 수집/카운트한 뒤, 그걸 기반으로 custom 페이징을 할 수도 있습니다.

하지만 데이터가 커지면 네트워크/메모리/CPU 모두 말도 안되게 필요할거고, 일정 규모 이상 되는 운영 환경에서는 사실상 사용할 수 없는 방식입니다.(검색 한번에 10분이 넘는다면..?)

이런 이유로 벡터 + 필터 쿼리에 대한 정확한 total count를 매 요청마다 계산하는 건, ANN/HNSW 기반 벡터 DB 설계에서 속도·리소스·알고리즘 특성상 비용이 너무 크고, 개념적으로도 애매해서 아직 기능을 넣지 않은 것으로 보입니다.

그래서 이 선택지는 과감하게 제외하는게 현실적으로 정신적으로 이롭습니다.


6. 정렬도 문제

Vector DB로 검색을 구현하는 과정에서 한 가지 더 짚고 넘어가야 할 점이 “정렬의 의미” 입니다.

위에서 언급했듯, Vector DB의 기본 역할은 임베딩된 쿼리 벡터와 가장 근접한 topK 데이터를 찾는 것입니다.
즉, 검색어(벡터)와의 관련도(유사도) 순으로 이미 정렬된 결과 집합을 반환합니다.
이 단계에서의 정렬 기준은 오로지 벡터 유사도(score)입니다.

문제는 여기서 한 발 더 나아가, 많은 서비스가 원하는 것처럼 벡터로 비슷한 것만 골라오고 동시에 생성일자, 이름 같은 필드 기준으로 정확하게 정렬된 데이터를 제공하고 싶어할 때입니다.

이때 제가 처음에 했던 생각이

“상위 N개만 벡터로 검색한 뒤, 그 안에서 가격순·이름순으로 다시 정렬하면 되는 것 아닌가?”

인데, 이 방식은 기술적으로는 가능하지만, 의미상 전체 데이터에 대한 가격순/이름순 정렬이라고 부를 수는 없습니다. 벡터 검색으로 가져온 것은 전체 데이터 중 상위 N개의 후보 집합일 뿐이고 그 안에서만 가격순으로 재배치하는 것은 Top-N 후보 재랭킹이지, 전체 결과의 전역 정렬(global sort)이 아닙니다.

쉽게 말해 사용자 입장에서는 화면에 가격순이라고 써 있으면 당연히 전체 결과가 가격 오름차순으로 정렬되어 있고 1페이지, 2페이지, 3페이지를 아무리 넘어가도 뒤 페이지에서 더 싼 상품이 갑자기 튀어나오지 않는다고 생각합니다.

하지만 Vector DB에서 topK + 재랭킹만으로 이걸 구현하면,

  • 1페이지에는 특정 범위의 후보 N개가 들어 있고
  • 2페이지를 넘어갔을 때, 1페이지보다 더 낮은 가격/더 이른 날짜가 뒤에서 튀어나올 수 있는 구조가 됩니다.

이렇게 개발하면 사실상 기대와 완전히 어긋나는 동작이고, 사용자 입장에서는 엥? 할 수 밖에 없게 동작하게 됩니다.


전체 데이터에 대해 벡터 유사도 + 가격/날짜까지 모두 반영한 전역 정렬을 하려면 매우 큰 후보 집합에 대해 벡터 distance/score를 계산하고 payload(가격, 날짜 등)를 함께 읽은 뒤 다시 전체 정렬을 해야 합니다.

이는 ANN이라는 근사 최근접 탐색을 사용하는 Vector DB의 설계 목적(빠르게 topK 찾기)과 정면으로 충돌합니다. 전용 Vector DB 하나에 유사도 + 완전한 가격/날짜 정렬까지 모두 맡기면, 사실상 전역 스캔에 가까운 비용을 감수해야 하는거라서 Vector DB의 가장 큰 장점인 유사도 검색의 의미가 퇴색됩니다.

또, 몇 점 이상의 score를 매칭으로 볼 것인가? 같은 threshold 설계도 애매한 문제로 남습니다.

위에서 커서 기반 페이지네이션을 사용해야 하는 이유에 대해 서술하였는데, RDB에서도 마찬가지지만 커서 기반 방식은 아래와 같이 ORDER BY를 쓰는 컬럼이 커서 컬럼이어야 합니다. ORDER BY name 과 같은 다른 기준으로 정렬을 바꾼다면 별도의 데이터가 표시되고 즉 커서는 정렬 키와 1:1로 항상 묶여야 합니다.

SELECT *
FROM table
WHERE created_at > :last_created_at
ORDER BY created_at ASC
LIMIT 20;

비슷한 개념으로 벡터 DB도 Cosine 유사도 기반으로 정렬된 데이터를 다시 name 기준 정렬을 얹어야하는거죠. 그럼 페이징은 유사도 순으로 되어있는데 정렬을 바꾸면 유사도 순으로 볼 수 없기에 더 이상 벡터 검색 기능의 의미가 사라지는겁니다.

Vector DB는 벡터 유사도 기준으로 상위 결과를 빠르게 찾고 보여주는 데 최적화된 도구이고, 전체 데이터에 대해 가격·날짜 등으로 완전히 정렬된 리스트를 보장하는 도구는 아닌걸로 보입니다.

아무 생각도 없이 전부 되겠지? 생각을 했었는데 만능인 도구는 아닌게 Vector DB라고 생각되고, 만약 강력한 검색 기능이 필요하다면 통합 검색과 유사도 검색 두 가지를 제공하는 방식도 충분히 가능해보입니다. OpenSearch나 ElasticSearch처럼 검색엔진을 활용하는 하이브리드 방식을 추후에 생각해봐도 좋을 것 같습니다.