임의 형태의 Payload 인덱싱

임의 형태의 Payload 인덱싱 (Indexing Payloads of Random Shape)

⏱️ 소요 시간 25분 | 난이도: 중급 출처: Qdrant 공식 문서 — index-dynamic-payloads

여러분, 대부분의 컬렉션은 고정된 스키마를 가져요. price, category, timestamp 같은 필드가 정해져 있죠. 이 튜토리얼은 payload 키 자체가 데이터인 경우를 다뤄요. 각 벤더의 원시 피드(raw feed)를 받아들이는 카탈로그 같은 경우예요. 그런 컬렉션은 수천 개의 서로 다른 키를 담을 수 있는데, 대부분 몇 개의 point에만 존재해요. 사용자가 그중 어떤 키로든 필터링해야 할 때, 직관적인 해결책은 키가 나타날 때마다 인덱스를 만드는 건데, 이는 확장성이 나빠요. 이 튜토리얼은 열려 있는 키(open-ended key)를 **값(value)**으로 재구성해서, 작고 고정된 payload 인덱스 집합과 중첩 필터(nested filter)로 전체 키 공간을 커버하는 방법을 보여줘요.

설정 (Setup)

클라이언트를 연결하고 컬렉션을 만들어요. 벡터는 이 패턴에 부수적이므로 작고 일반적인 값으로 두면 돼요. Cloud Quickstart가 클러스터 생성과 엔드포인트·API 키 획득 방법을 다뤄요.

from qdrant_client import QdrantClient, models

client = QdrantClient(
    url="https://your-endpoint.cloud.qdrant.io:6333",
    api_key="<paste-your-key>",
)

client.create_collection(
    collection_name="catalog",
    vectors_config=models.VectorParams(size=128, distance=models.Distance.COSINE),
)

키당 인덱스 하나의 함정 (The One-Index-Per-Key Trap)

반응형 접근법은 키가 나타나는 즉시 각 키를 인덱스해요:

# 안티패턴: 인제스트 시점에 고유 키마다 인덱스 하나를 만든다.
for key in incoming_keys:
    client.create_payload_index(
        collection_name="catalog",
        field_name=key,
        field_schema=models.PayloadSchemaType.KEYWORD,
    )

처음 백 개 정도의 키에서는 잘 동작해요. 하지만 키 공간이 커지면 각 키가 인덱스를 하나씩 추가하므로 부담이 돼요. 여기서 컬렉션은 10,000개의 point로 고정되어 있고 키 수만 달라져요. 그래서 메모리와 빌드 시간의 증가는 데이터가 아니라 인덱스 그 자체에서 오는 거예요:

Points 고유 키 (= 인덱스) 빌드 시간 추가 메모리
10,000 300 19.7 s +377 MiB
10,000 1,000 63.4 s +1,190 MiB
10,000 3,000 220.0 s +3,203 MiB

40,000키 카탈로그로 확장하면 수십 GB의 인덱스 메모리로 이어질 거예요.

위의 메모리와 빌드 시간만이 전부가 아니에요. 사용자 업로드가 스키마를 정의하므로 대비할 고정 필드 목록이 없고, 인덱스 집합이 고정된 채로 유지되는 대신 데이터와 함께 계속 늘어나요.

키를 값으로 재구성하기 (Reshape Keys Into Values)

다양성은 키 위치에 있고, 각 고유 키가 저마다의 인덱스를 강제해요. 이를 값 위치로 옮기면 더 이상 새 인덱스를 만들지 않아요. payload를 고정 필드 아래의 {key, value} 객체 배열(엔티티-속성-값(Entity-Attribute-Value) 형태)로 재구성해요. 이는 인제스트 코드에서, upsert 전에 한 번만 하면 돼요.

{ "vendor": "acme", "screen_size": "27in" }
     ↓ your code에서 reshape
{ "attrs": [
    {"key": "vendor", "value": "acme"},
    {"key": "screen_size", "value": "27in"}
] }

이제 모든 point가 같은 필드 이름을 담아요: attrs, 그리고 그 안의 keyvalue. 이 집합은 절대 늘어나지 않아요. "vendor""screen_size"는 이제 key 필드 안의 문자열 값이 되므로, 새 속성 이름은 새 인덱스가 아니라 그냥 저장할 값 하나일 뿐이에요.

그 두 필드를 인제스트 전, 설정 단계에서 한 번 인덱스해요:

client.create_payload_index(
    collection_name="catalog",
    field_name="attrs[].key",
    field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
    collection_name="catalog",
    field_name="attrs[].value",
    field_schema=models.PayloadSchemaType.KEYWORD,
)

재구성된 형태로 point를 upsert해요. 각 원시 속성이 배열의 항목 하나가 돼요:

client.upsert(
    collection_name="catalog",
    points=[
        models.PointStruct(
            id=1,
            vector=[0.1] * 128,
            payload={
                "attrs": [
                    {"key": "vendor", "value": "acme"},
                    {"key": "refresh_rate", "value": "144"},
                ]
            },
        ),
    ],
)

이제 인덱스 개수는 데이터에 고유 키가 몇 개든 둘로 고정돼요. 같은 10,000-point 컬렉션에서, 두 인덱스는 0.2초 만에 빌드되고 24 MiB를 추가해요. 반면 키당 인덱스 하나(1,000키)는 63초와 1,190 MiB가 들죠. 인제스트는 더 이상 인덱스를 만들지 않아요.

중첩 필터로 키와 값 묶기 (Tie Key to Value With a Nested Filter)

attrs[].key"refresh_rate"로, attrs[].value"144"로 두 독립 조건으로 필터링하면 함정이 있어요. Qdrant는 어떤 요소가 그 키를 갖고 어떤 요소가 그 값을 갖는 point를 매칭하는데, 반드시 같은 요소일 필요는 없어요. refresh_rate가 60이고 weight가 144인 모니터도 매칭될 수 있죠.

중첩 필터(nested filter)가 이 문제를 해결해요. 내부 필터를 각 배열 요소에 대해 그 자체로 평가하므로, 두 조건이 한 요소 안에서 모두 성립해야 해요.

results = client.query_points(
    collection_name="catalog",
    query_filter=models.Filter(
        must=[
            models.NestedCondition(
                nested=models.Nested(
                    key="attrs",
                    filter=models.Filter(
                        must=[
                            models.FieldCondition(
                                key="key",
                                match=models.MatchValue(value="refresh_rate"),
                            ),
                            models.FieldCondition(
                                key="value",
                                match=models.MatchValue(value="144"),
                            ),
                        ]
                    ),
                )
            )
        ]
    ),
)

nested 안에서는 내부 키가 배열 요소에 상대적이에요. 즉 keyattrs[].key가 아니에요. point는 적어도 하나의 요소가 내부 필터 전체를 만족하면 매칭돼요. 서로 다른 두 속성이 필요하면 바깥 mustnested 블록 두 개를 사용하세요.

범위를 위해 값 타입 유지하기 (Keep Value Types for Ranges)

문자열 배열은 정확 매칭(exact match)을 처리하지만, 문자열에는 숫자 순서가 없어서 price 500 미만 같은 **범위 질의(range query)**가 불가능해져요. 속성을 값 타입별로 병렬 배열로 나누세요. 문자열, 숫자, 불리언 각각에 맞는 스키마로 인덱스해요.

reshape는 하나의 원시 payload dict(도착하는 속성들)를 받아 각 항목을 그 타입의 배열로 정렬해요:

def reshape(raw: dict) -> dict:
    """원시 payload의 속성을 값 타입별로 배열에 하나씩 정렬한다."""
    strings, numbers, bools = [], [], []
    for key, value in raw.items():
        # bool은 Python에서 int의 서브클래스이므로 먼저 확인한다
        if isinstance(value, bool):
            bools.append({"key": key, "value": value})
        elif isinstance(value, (int, float)):
            numbers.append({"key": key, "value": float(value)})
        else:
            strings.append({"key": key, "value": value})
    return {"attrs": strings, "attrs_num": numbers, "attrs_bool": bools}

혼합 타입 payload는 타입별 배열 하나로 나뉘어요:

reshape({"vendor": "acme", "price": 499, "in_stock": True})
# {
#   "attrs": [{"key": "vendor", "value": "acme"}],
#   "attrs_num": [{"key": "price", "value": 499.0}],
#   "attrs_bool": [{"key": "in_stock", "value": True}],
# }

숫자 배열의 키는 keyword로, 값은 float로 인덱스해요. 그러면 Range를 지원해요:

client.create_payload_index(
    collection_name="catalog",
    field_name="attrs_num[].key",
    field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
    collection_name="catalog",
    field_name="attrs_num[].value",
    field_schema=models.PayloadSchemaType.FLOAT,
)

범위 질의는 attrs_num에 대한 nested 필터에 valueRange 조건이 붙은 형태예요:

models.NestedCondition(
    nested=models.Nested(
        key="attrs_num",
        filter=models.Filter(
            must=[
                models.FieldCondition(
                    key="key",
                    match=models.MatchValue(value="price"),
                ),
                models.FieldCondition(
                    key="value",
                    range=models.Range(lt=500),
                ),
            ]
        ),
    )
)

이것이 객체 배열이 핵심인 이유예요. 하나의 형태가 정확 매칭과 범위를 모두 처리하므로, 이것만 복사해도 둘 다 옳아요. 이 분할은 모든 payload 인덱스 타입으로 확장돼요. 불리언, datetime, geo, uuid, full-text 값 각각이 자기 타입의 배열을 갖고, 맞는 스키마로 인덱스되고 같은 중첩 필터로 조회돼요.

key=value 연결로 정확 매칭 최적화하기 (Optimize Exact Match by Concatenating key=value)

정확 매칭만 필요한 범주형(categorical) 속성이라면, 각각을 평평한 문자열 배열의 단일 key=value keyword 용어로 저장하고 한 번 인덱스할 수 있어요.

# Payload: {"attrs_flat": ["vendor=acme", "screen_size=27in"]}
client.create_payload_index(
    collection_name="catalog",
    field_name="attrs_flat",
    field_schema=models.PayloadSchemaType.KEYWORD,
)
results = client.query_points(
    collection_name="catalog",
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="attrs_flat",
                match=models.MatchValue(value="vendor=acme"),
            ),
        ]
    ),
)

인덱스는 두 개 대신 하나, 중첩 블록도 없고, 키와 값이 한 용어에 묶이므로 같은 요소 함정이 구조적으로 사라져요. 그것보다 더 빠르기도 해요. 같은 인스턴스에서 300회 정확 매칭 조회의 중앙값을 측정했어요:

Points 형태 인덱스 중앙값
10,000 중첩 key-value 2 1.54 ms
10,000 연결(concatenated) 1 1.01 ms
100,000 중첩 key-value 2 2.22 ms
100,000 연결(concatenated) 1 1.32 ms

연결 방식은 중앙값 기준 30~40% 더 빠르고, point가 늘어날수록 격차가 커져요. 한계도 분명해요. 정확 매칭만 되므로 범위가 안 되고, 용어가 별개의 key=value 쌍이라 값의 카디널리티가 높으면 인덱스가 부풀어요. 값의 타입이 사라지며, "키 X가 존재하는가" 질의는 text-prefix 인덱스나 별도의 attr_keys 배열이 필요해요. 범주형·정확 매칭 속성에 맞는 방식이지, 전체 문제를 위한 건 아니에요.

언제 어떤 것을 써야 할까요 (When to Use Which)

실제 설계는 한 point에서 형태를 섞어 써요. 각 속성을 선호가 아니라 질의 타입에 따라 라우팅하세요.

질의 요구 형태
범주형, 정확 매칭만 연결 key=value 용어, keyword 인덱스 하나
범위, datetime, 고카디널리티 값 타입별 {key, value} 배열 + 중첩 필터
매 요청마다 조회되는 소수의 핫 필드 전용 최상위 payload 인덱스

한 point가 attrs_flat과 타입별 배열을 둘 다 담을 수 있어요. 이 패턴에만 국한된 한계가 하나 있는데, nested 안에서는 has_id가 지원되지 않는다는 거예요. 필요한 경우 중첩 블록 옆의 인접한 must 절에 넣으세요.

더 알아보기 (Learn more)