멀티테넌시 설정하기

멀티테넌시 설정하기 (multitenancy)

많은 애플리케이션이 공유 배포에서 여러 사용자·고객·조직(즉 '테넌트' tenant)을 서비스해요. 각 테넌트의 데이터는 반드시 격리되어야 합니다. 테넌트가 검색할 때 자기 데이터만 보아야 하죠. Qdrant는 공유 배포 안에서 테넌트 데이터를 분리하는 여러 해법을 제공합니다. 이 페이지를 읽고 나면 언제 컬렉션을 나누고, 언제 하나의 컬렉션 안에서 테넌트를 분리해야 하는지, 그리고 각 방식의 설정법을 알게 됩니다.

> 출처: [Qdrant 공식 문서 — multitenancy](https://qdrant.tech/documentation/manage-data/multitenancy)

테넌트마다 별도의 컬렉션을 만드는 건 거의 효율적이지 않아요. 컬렉션마다 자기만의 리소스 오버헤드가 있어서, 컬렉션을 많이 만들면 비용이 빠르게 늘어나죠. 엄격한 격리가 필요한 테넌트 수가 적을 때만 여러 컬렉션을 만드세요.

Qdrant Cloud는 기본적으로 클러스터당 최대 1000개 컬렉션으로 제한해요.

대신 모든 테넌트를 하나의 컬렉션에 두고, 다음 세 가지 접근법 중 하나로 격리합니다.

  • Payload로 파티셔닝: 테넌트를 식별하는 payload 필드로 포인트를 필터링해요. 많고 비슷한 크기의 작은 테넌트에 효율적이에요.
  • 사용자 정의 샤딩(User-defined sharding): 테넌트마다 전용 샤드를 하나씩 줘요. 리소스 오버헤드 일부를 격리 강화와 맞바꾸며, 더 적은 수의 더 큰 테넌트에 가장 잘 맞아요.
  • 계층형 멀티테넌시(Tiered multitenancy): 두 방식을 결합해요. 작은 테넌트는 공유 샤드 하나에, 큰 테넌트는 각자 전용 샤드로 승격시킵니다.

Payload로 파티셔닝하기 (Partition by Payload)

공유 컬렉션에서 데이터를 파티셔닝하려면, 각 포인트에 테넌트를 식별하는 payload 필드를 추가하세요. 그다음 이 필드로 필터링해 각 테넌트가 자기 데이터만 보도록 합니다.

이 예시에서는 테넌트 필드로 group_id를 사용해요. 먼저 테넌트 필드에 키워드(keyword) payload 인덱스를 만듭니다.

PUT /collections/{collection_name}/index
{
    "field_name": "group_id",
    "field_schema": {
        "type": "keyword",
        "is_tenant": true
    }
}

is_tenant 파라미터는 v1.11.0부터 사용할 수 있어요. 이전 버전에서는 키워드 인덱스 생성의 기본 옵션을 사용하세요.

is_tenant=true 파라미터는 선택 사항이지만, 지정하면 Qdrant가 컬렉션의 사용 패턴에 대한 추가 정보를 얻어요. 설정되면 Qdrant는 같은 테넌트의 벡터가 함께 위치하도록 저장 구조를 구성해서, 쿼리 시 순차 읽기(sequential read)를 활용해 성능을 크게 개선할 수 있어요.

is_tenant=true를 쓰면 테넌트 ID별로 벡터가 그룹화되어 더 효율적인 디스크 읽기가 가능해집니다. 파일 전체에 걸친 많은 랜덤 시크(random seek) 대신, Qdrant는 특정 테넌트 데이터를 순차 읽기 하나로 읽을 수 있어요.

이제 payload에 테넌트 ID를 넣어 포인트를 삽입합니다:

PUT /collections/{collection_name}/points
{
    "points": [
        {
            "id": 1,
            "payload": {"group_id": "user_1"},
            "vector": [0.9, 0.1, 0.1]
        },
        {
            "id": 2,
            "payload": {"group_id": "user_1"},
            "vector": [0.1, 0.9, 0.1]
        },
        {
            "id": 3,
            "payload": {"group_id": "user_2"},
            "vector": [0.1, 0.1, 0.9]
        },
    ]
}

키 이름이 꼭 group_id일 필요는 없어요. 어떤 이름이든 선택할 수 있습니다.

테넌트 필드(group_id)에 대한 필터를 걸어 쿼리하면 한 테넌트의 데이터만 반환됩니다:

POST /collections/{collection_name}/points/query
{
    "query": [0.1, 0.1, 0.9],
    "filter": {
        "must": [
            {
                "key": "group_id",
                "match": {
                    "value": "user_1"
                }
            }
        ]
    },
    "limit": 10
}

성능 보정하기 (Calibrate Performance)

많은 테넌트가 하나의 컬렉션을 공유하면 그 벡터들이 모두 함께 인덱싱되므로, 인덱싱 속도가 병목이 될 수 있어요. 이 병목을 피하려면 컬렉션의 전역 벡터 인덱스를 비활성화하고 각 그룹에 대해서만 인덱스를 만드는 걸 고려해 보세요.

이 전략을 쓰면 Qdrant가 테넌트별로 벡터를 독립적으로 인덱싱해서 과정이 크게 빨라집니다.

이렇게 구현합니다:

  • HNSW 설정에서 payload_m을 0이 아닌 값(예: 16)으로 설정한다.
  • HNSW 설정에서 m을 0으로 설정한다. 이러면 컬렉션의 전역 인덱스가 비활성화됩니다.
PUT /collections/{collection_name}
{
    "vectors": {
      "size": 768,
      "distance": "Cosine"
    },
    "hnsw_config": {
        "payload_m": 16,
        "m": 0
    }
}

제약 사항(Limitations):

  • 전역 요청(group_id 필터가 없는 요청)은 모든 그룹을 스캔해 최근접 이웃을 찾아야 하므로 더 느려요.
  • IDF 수정자와 함께 희소 벡터 검색을 쓸 때, payload 기반 파티셔닝만으로는 IDF 통계를 격리하지 못해요. 기본적으로 모든 테넌트가 같은 샤드 전체의 용어 빈도(term frequency)를 공유합니다. 통계를 단일 테넌트로 한정하려면 idf 검색 파라미터를 사용하세요.

테넌트별 IDF 통계 (Per-Tenant IDF Statistics)

v1.19.0부터 사용 가능해요.

BM25와 miniCOIL 희소 벡터 검색은 역문서 빈도(IDF, inverse document frequency)를 사용해 일치하는 문서에 점수를 줘요. 드문 용어일수록 더 높은 가중치를 받죠. IDF를 계산하려면 두 가지 통계가 필요해요. 전체 문서 수와 각 용어를 포함하는 문서 수가 그것이에요.

기본적으로 이 통계는 쿼리 대상인 전체 샤드에 대해 계산됩니다. payload 필터 기반 멀티테넌시를 쓰면 모든 테넌트의 어휘가 하나의 통계로 섞여서, 어떤 용어의 IDF가 더 이상 특정 테넌트 데이터 안에서의 희소함을 반영하지 못하게 돼요.

idf 검색 파라미터는 Qdrant가 통계를 계산할 모집단(즉 IDF 코퍼스)을 좁혀 이 문제를 해결해요. 데이터를 한정하는 payload 필터를 받습니다.

이 필터는 검색(리트리벌) 필터와 독립적이에요. IDF 코퍼스를 결정하는 필터는 보통 리트리벌 필터보다 더 넓어요. 예를 들어 아래에서 IDF는 리트리벌 필터가 연도로 더 좁혀지더라도 테넌트의 모든 데이터에 대해 계산됩니다.

POST /collections/{collection_name}/points/query
{
    "query": {
        "text": "time travel",
        "model": "qdrant/bm25"
    },
    "using": "title-bm25",
    "filter": {
        "must": [
            { "key": "group_id", "match": { "value": "user_1" } },
            { "key": "year", "match": { "value": 2024 } }
        ]
    },
    "params": {
        "idf": {
            "corpus": {
                "must": [
                    { "key": "group_id", "match": { "value": "user_1" } }
                ]
            }
        }
    },
    "limit": 10,
    "with_payload": true
}

idf는 생략했을 때와 같은 global(샤드 전체 통계)이 기본값이에요.

  • idf 필터에서 쓰려는 필드는 payload 인덱스 및/또는 테넌트 인덱스를 만들어 주세요. Qdrant Cloud에서는 엄격 모드가 기본으로 켜져 있고, 인덱스되지 않은 필드의 필터는 거부됩니다.
  • IDF 수정자가 활성화된 희소 벡터의 쿼리에만 적용돼요. IDF가 없는 벡터에 idf를 쓰면 오류가 발생합니다.
  • 코퍼스 필터가 일치하는 포인트가 없으면 IDF 통계는 샤드 전체 통계로 fallback하지 않아요. 대신 모든 용어가 같은 상수 가중치를 받아서, 순위가 희소성 신호 없는 순수 TF로 퇴화합니다.
  • 사용자 정의 샤딩을 쓸 때 단일 테넌트 전용 샤드로 검색 요청을 라우팅하면 IDF가 이미 그 테넌트 데이터로 한정돼요. 이 샤드 지역성은 idf 필터에도 적용됩니다. 필터가 쿼리 대상 샤드와 다른 샤드에 있는 포인트와 일치하면 Qdrant는 샤드를 넘어 이를 충족시키지 않아요. 로컬에 존재하는 중첩분(비어 있거나 부분적일 수 있음)에서 조용히 통계를 계산합니다.

사용자 정의 샤딩 (User-Defined Sharding)

v1.7.0부터 사용 가능해요.

payload 필드로 테넌트를 필터링하는 대신, 테넌트마다 전용 샤드를 하나씩 주는 방법도 있어요. Qdrant는 포인트별로 샤드를 직접 지정하게 해 주므로, 테넌트의 연산은 항상 그 테넌트의 샤드만 건드립니다. 이 방식은 리소스 오버헤드 일부(각 샤드는 자기만의 저장·인덱스 구조를 가져요)를 더 강한 격리와 맞바꾸며, 적당한 수의 큰 테넌트에 가장 잘 맞아요.

이 방식을 쓰려면 사용자 정의 샤딩(custom sharding이라고도 해요)을 활성화한 컬렉션을 만듭니다:

PUT /collections/{collection_name}
{
    "shard_number": 1,
    "sharding_method": "custom"
    // ... other collection parameters
}

그런 다음 테넌트 ID를 샤드 키로 사용해 테넌트마다 샤드를 만듭니다 (API 참조):

PUT /collections/{collection_name}/shards
{
  "shard_key": "{shard_key}"
}

포인트를 테넌트의 샤드로 라우팅하려면 upsert 요청에 shard_key 필드를 제공하세요:

PUT /collections/{collection_name}/points
{
    "points": [
        {
            "id": 1111,
            "vector": [0.1, 0.2, 0.3]
        },
    ],
    "shard_key": "user_1"
}

쿼리 요청에도 같은 shard_key를 지정하면 그 테넌트의 샤드 안에서만 검색합니다.

샤드는 상당한 리소스를 요구하므로, 테넌트 수를 각자 자신의 샤드를 가질 수 있을 만큼 낮게 유지하세요. 테넌트 수가 많다면 payload 파티셔닝이나 계층형 멀티테넌시를 대신 사용합니다.

계층형 멀티테넌시 (Tiered Multitenancy)

v1.16.0부터 사용 가능해요.

실전 애플리케이션에서 테넌트가 항상 고르게 분포하진 않아요. 예를 들어 SaaS 애플리케이션에는 큰 고객이 몇 명, 작은 고객이 많을 수 있죠. 큰 테넌트는 더 많은 리소스와 격리가 필요하고, 작은 테넌트는 오버헤드를 너무 늘리면 안 됩니다.

한 가지 해법은 애플리케이션 레벨 로직으로 테넌트를 크기나 리소스 요구에 따라 서로 다른 컬렉션으로 나누는 거예요. 하지만 이 방식엔 단점이 있어요. 어떤 테넌트가 커지고 어떤 테넌트가 작게 남을지 미리 알 수 없을 수 있죠. 게다가 애플리케이션 레벨 로직은 시스템 복잡도를 높이고, 테넌트 배치를 관리할 추가 진실의 원천(source of truth)을 요구합니다.

이 문제를 해결하기 위해 Qdrant는 계층형 멀티테넌시라는 내장 메커니즘을 제공해요. 계층형 멀티테넌시를 쓰면 하나의 컬렉션 안에서 두 수준의 테넌트 격리를 구현할 수 있습니다.

  • 작은 테넌트를 단일 공유 샤드에 함께 둔다.
  • 큰 테넌트를 각자 전용 샤드로 격리한다.

계층형 멀티테넌시를 구현하게 해 주는 Qdrant의 세 가지 구성요소가 있어요.

  • 사용자 정의 샤딩(User-defined Sharding): 컬렉션 안에 이름 있는 샤드를 만들 수 있게 해 줘요. 큰 테넌트를 자신의 샤드로 격리할 수 있게 합니다.
  • Fallback 샤드(Fallback shards): 요청을 전용 샤드(존재한다면) 또는 공유 fallback 샤드로 라우팅하는 특별한 라우팅 메커니즘이에요. 테넌트가 전용인지 공유인지 알 필요 없이 요청을 통일되게 유지할 수 있게 해 줍니다.
  • 테넌트 승격(Tenant promotion): 테넌트가 충분히 커지면 공유 fallback 샤드에서 자신의 전용 샤드로 이동시키는 메커니즘이에요. 이 과정은 Qdrant 내부 샤드 전송 메커니즘을 기반으로 하므로 애플리케이션에 완전히 투명합니다. 승격 과정은 읽기와 쓰기 요청 모두를 지원해요.

계층형 멀티테넌시 구성하기 (Configure Tiered Multitenancy)

계층형 멀티테넌시를 활용하려면 사용자 정의 샤딩이 있는 컬렉션을 만들고 그 안에 fallback 샤드를 만들어야 해요.

PUT /collections/{collection_name}
{
    "shard_number": 1,
    "sharding_method": "custom"
    // ... other collection parameters
}

shard_number를 1로 설정하세요. 나중에 테넌트를 전용 샤드로 승격하는 건 단일 샤드에서만 가능해요. replication_factor를 1보다 크게 설정하는 건 괜찮습니다.

먼저 작은 테넌트를 저장할 fallback 샤드를 만듭니다. 이름을 default로 지어 볼게요.

PUT /collections/{collection_name}/shards
{
  "shard_key": "default",
  "shards_number": 1
}

컬렉션 생성 때와 마찬가지로 shards_number를 1로 설정하세요.

컬렉션이 전용 및 공유 테넌트를 모두 허용하므로, 여전히 payload 기반 테넌시를 구성해야 해요. 'Payload로 파티셔닝' 섹션에서 설명한 것과 동일하게, 테넌트 필드(이 예시에서는 group_id)에 is_tenant=true로 payload 인덱스를 만드세요.

PUT /collections/{collection_name}/index
{
    "field_name": "group_id",
    "field_schema": {
        "type": "keyword",
        "is_tenant": true
    }
}

계층형 멀티테넌트 컬렉션에 쓰기 (Write to a Tiered Multitenant Collection)

이제 컬렉션에 데이터를 업로드할 수 있어요. 올바른 샤드에 도달하도록 각 요청에 샤드 키 셀렉터(shard key selector) 를 지정하세요. 샤드 키 셀렉터는 두 개의 키를 지정해야 해요.

  • target 샤드 — 테넌트의 전용 샤드 이름(존재하거나 존재하지 않을 수 있음).
  • fallback 샤드 — 공유 fallback 샤드의 이름(이 예시에서는 default).
PUT /collections/{collection_name}/points
{
    "points": [
        {
            "id": 1,
            "payload": {"group_id": "user_1"},
            "vector": [0.9, 0.1, 0.1]
        }
    ],
    "shard_key": {
        "fallback": "default",
        "target": "user_1"
    }
}

라우팅 로직은 이렇게 동작해요:

  • target 샤드가 존재하고 활성 상태면 요청이 그쪽으로 라우팅된다.
  • target 샤드가 존재하지 않으면 요청이 fallback 샤드로 라우팅된다.

계층형 멀티테넌트 컬렉션 쿼리하기 (Query Tiered Multitenant Collection)

포인트를 쿼리할 때는 같은 샤드 키 셀렉터를 지정하고 테넌트 필드(이 예시에서는 group_id)로 필터링하세요. 테넌트 필터 값은 target 샤드 키와 일치해야 해요.

POST /collections/{collection_name}/points/query
{
    "query": [0.1, 0.1, 0.9],
    "filter": {
        "must": [
            {
                "key": "group_id",
                "match": {
                    "value": "user_1"
                }
            }
        ]
    },
    "shard_key": {
        "fallback": "default",
        "target": "user_1"
    },
    "limit": 10
}

테넌트를 전용 샤드로 승격하기 (Promote Tenant to Dedicated Shard)

테넌트가 충분히 커지면 자신의 전용 샤드로 승격할 수 있어요. 그러려면 먼저 테넌트용 새 샤드를 만듭니다.

PUT /collections/{collection_name}/shards
{
  "shard_key": "user_1",
  "shards_number": 1,
  "replication_factor": 1,
  "initial_state": "Partial"
}

샤드는 아직 데이터를 받아야 하므로 Partial 상태로 만들어집니다. 이전과 마찬가지로 컬렉션의 기본 shards_number인 1을 써요. replication_factor도 처음에는 1로 설정해야 합니다. 테넌트 데이터를 새 샤드에 복제한 뒤에 복제본을 만들 수 있어요.

데이터 전송을 시작하려면 replicate_points API를 사용합니다:

POST /collections/{collection_name}/cluster
{
    "replicate_points": {
        "filter": {
            "must": {
                "key": "group_id",
                "match": {
                    "value": "user_1"
                }
            }
        },
        "from_shard_key": "default",
        "to_shard_key": "user_1"
    }
}

전송이 완료되면 target 샤드가 Active가 되고, 그 테넌트의 모든 요청이 자동으로 그쪽으로 라우팅돼요. 이 시점에 공유 fallback 샤드에서 테넌트 데이터를 삭제해 공간을 확보해도 안전합니다.

이제 새 샤드의 복제본을 만들 수 있어요.

제약 사항(Limitations):

  • fallback 샤드와 전용 테넌트 샤드는 모두 shards_number가 1이어야 해요. 새 전용 테넌트 샤드도 생성 시점에 replication_factor가 1이어야 합니다. 샤드 전송 메커니즘이 단일 샤드에서만 동작하기 때문이에요. 원하면 포인트 전송 완료 후 복제 인자를 늘릴 수 있습니다.
  • 즉, fallback 샤드를 공유하는 모든 작은 테넌트가 단일 샤드, 나아가 단일 노드의 저장·쓰기 용량에 들어맞아야 해요. 전용 테넌트 샤드도 마찬가지입니다. 향후 릴리스에서 이 제한을 제거할 계획이에요.
  • 컬렉션과 마찬가지로 전용 샤드도 어느 정도 리소스 오버헤드를 도입해요. 클러스터당 전용 샤드를 천 개 이상 만들지 마세요. 테넌트 승격의 권장 임계값은 단일 컬렉션의 인덱싱 임계값과 같으며, 대략 20,000개 포인트입니다.

더 알아보기 (Learn more)