Explain Plan
Explain Plan
Pinot 내의 쿼리 실행은 파이프라인 방식으로 실행되어 최종 결과를 만드는 연산자(operator) 시퀀스로 모델링돼요. EXPLAIN PLAN FOR 구문을 사용해 쿼리의 실행 계획을 얻을 수 있으며, 이는 쿼리를 더 최적화하는 데 유용해요.
경고: explain plan 출력 형식은 아직 개발 중이며 향후 릴리스에서 변경될 수 있어요. 이 개발 중이라는 표시는 explain plan 출력 형식에만 적용되며, 일반적으로 사용 가능한(GA) 핵심 multi-stage 엔진에는 적용되지 않아요. Pinot explain plan은 사람이 읽을 수 있으며 디버깅 및 최적화 목적으로 사용하기 위한 것이에요. 특히 explain plan을 자동화된 스크립트나 도구에서 사용할 때 중요해요. 테이블이나 JSON으로 반환되는 explain plan조차도 릴리스 간 안정적이라고 보장되지 않아요.
출처: 문서
본문
Pinot는 쿼리 엔진과 얻고자 하는 세분성(granularity) 또는 세부 정보에 따라 다른 유형의 explain plan을 지원해요.
graph LR
EXPLAIN
STAGE{"single or \n multi-stage?"}
SSE_Q_VERBOSE{verbose?}
SSE_SIMPLE[brief SSE]
SSE_EXTENDED[verbose SSE]
MSE_WORKERS[workers MSE]
MSE_LOGICAL[logical MSE]
MSE_Q_VERBOSE{verbose?}
MSE_IMPL_SIMPLE["brief segment MSE"]
MSE_IMPL_VERBOSE["verbose segment MSE"]
EXPLAIN --> STAGE
STAGE -- single --> SSE_Q_VERBOSE
STAGE -- multi --> MSE_Q_DISTRIBUTION{workers?}
SSE_Q_VERBOSE -- no --> SSE_SIMPLE
SSE_Q_VERBOSE -- yes --> SSE_EXTENDED
MSE_Q_DISTRIBUTION -- yes --> MSE_WORKERS
MSE_Q_DISTRIBUTION -- no --> MSE_Q_LOGICAL{logical?}
MSE_Q_LOGICAL -- yes --> MSE_LOGICAL
MSE_Q_LOGICAL -- no --> MSE_Q_VERBOSE
MSE_Q_VERBOSE -- yes --> MSE_IMPL_VERBOSE
MSE_Q_VERBOSE -- no --> MSE_IMPL_SIMPLE
세그먼트에 따른 다른 계획
세그먼트는 Pinot에서 데이터 저장 및 처리의 기본 단위예요. 쿼리가 실행되면 각 세그먼트에서 실행되고 결과는 병합돼요. 모든 세그먼트가 동일한 데이터 분포, 인덱스 등을 가지는 것은 아니에요. 따라서 쿼리 엔진은 세그먼트에 따라 쿼리를 다르게 실행하기로 결정할 수 있어요. 여기에는 다음이 포함돼요.
- 테이블 구성에 인덱스가 추가되거나 제거된 이후 새로고침되지 않은 세그먼트
- 범위 인덱스 같은 일부 인덱스를 사용할 수 없는, 수집 중인 실시간 세그먼트
- 쿼리 계획에 영향을 줄 수 있는 데이터 분포, 특히 컬럼의 최솟값과 최댓값
Pinot 쿼리는 수천 개의 세그먼트를 건드릴 수 있으므로, Pinot는 쿼리를 explain할 때 표시되는 [다른 쿼리][^1] 수를 최소화하려고 해요. 기본적으로 Pinot는 각 세그먼트에 대한 계획을 분석하고 단순화된 계획을 반환해요. 이 단순화가 어떻게 이루어지는지는 쿼리 엔진에 따라 달라지며, 아래에서 더 자세히 읽을 수 있어요.
각 세그먼트에 대한 계획을 보여주는 verbose 모드가 있어요. 이 모드는 explainPlanVerbose 쿼리 옵션을 true로 설정해 활성화하며, explain plan 문장 앞에 SET explainPlanVerbose=true;를 붙이면 돼요.
multi-stage 쿼리 엔진에 대한 Explain
multi-stage 쿼리 엔진의 더 복잡한 특성에 따라, 그 explain plan은 쿼리 실행의 다양한 측면에 맞춘(특화된)[^2] 계획을 얻도록 커스터마이즈할 수 있어요.
multi-stage 쿼리 엔진에는 3가지 유형의 explain plan이 있어요.
| 모드 | 기본 구문 | 세그먼트 계획이 활성화된 경우의 구문 | 설명 |
|---|---|---|---|
| Segment plan | SET explainAskingServers=true; EXPLAIN PLAN FOR |
EXPLAIN PLAN FOR |
세그먼트 특정 정보(예: 인덱스)를 포함해요. |
| Logical plan | EXPLAIN PLAN FOR 또는 EXPLAIN PLAN WITHOUT IMPLEMENTATION FOR |
EXPLAIN PLAN WITHOUT IMPLEMENTATION FOR |
가장 단순한 multi-stage 계획. 인덱스나 데이터 셔플 정보가 없어요. |
| Workers plan | EXPLAIN IMPLEMENTATION PLAN FOR |
EXPLAIN IMPLEMENTATION PLAN FOR |
서버 간 데이터 셔플을 이해하는 데 사용해요. 참고: 이 모드의 이름은 논의 중이며 향후 변경될 수 있어요. |
참고: 각 explain plan 모드를 선택하는 데 사용되는 구문은 혼란스러우며 향후 변경될 수 있어요.
Segment plan
세그먼트 계획은 데이터 분포, 인덱스 등 세그먼트 특정 정보를 포함하는 쿼리 실행 계획의 상세한 표현이에요.
이 모드는 Pinot 1.3.0에서 도입되었고 향후 릴리스에서 기본값이 될 예정이에요. 그동안 explainAskingServers 쿼리 옵션을 true로 설정하거나 explain plan 문장 앞에 SET explainAskingServers=true;를 붙여 사용할 수 있어요. 또는 broker 구성 pinot.query.multistage.explain.include.segment.plan을 true로 변경해 이 모드를 기본값으로 활성화할 수 있어요.
활성화 방식과 무관하게, 이 모드가 활성화되면 EXPLAIN PLAN FOR 구문에 세그먼트 정보가 포함돼요.
Verbose 및 brief 모드
Different plans for different segments에서 설명했듯이 기본적으로 Pinot는 쿼리를 explain할 때 표시되는 [다른 쿼리][^3] 수를 최소화하려고 해요. multi-stage에서 brief 모드는 고유한 세그먼트 계획을 Alternative(segments=[n]) 분기로 그룹화하며, 여기서 n은 해당 계획을 사용하는 세그먼트 수예요. 동등한 계획은 같은 분기로 집계되므로, 같은 계획이 100개 세그먼트에서 실행된다면 brief 모드는 한 번만 보여주고 문서 수 같은 가산 통계는 합산돼요.
일부 세그먼트가 다른 계획을 사용한다면 brief 모드는 세그먼트당 하나의 자식으로 되돌리는 대신 고유한 계획마다 하나의 Alternative 분기를 유지해요. 모든 세그먼트가 같은 계획을 공유할 때 Pinot는 병합 후 중복된 단일 Alternative 래퍼를 제거하므로, 일반적인 경우는 여전히 결합(combine) 노드 아래에 단일 자식으로 렌더링돼요.
verbose 모드에서는 각 세그먼트에 대해 세그먼트 이름과 모든 세그먼트 특정 정보를 포함해 하나의 계획이 표시돼요. 이는 어떤 세그먼트가 인덱스를 사용하지 않는지, 어떤 세그먼트가 다른 데이터 분포를 사용하는지 아는 데 유용할 수 있어요.
예시
-- SET explainAskingServer= true is required if
-- pinot.query.multistage.explain.include.segment.plan is false,
-- optional otherise
SET explainAskingServers=true;
EXPLAIN PLAN FOR
SELECT DISTINCT deviceOS, groupUUID
FROM userAttributes AS a
JOIN userGroups AS g
ON a.userUUID = g.userUUID
WHERE g.groupUUID = 'group-1'
LIMIT 100
반환:
Execution Plan
LogicalSort(offset=[0], fetch=[100])
PinotLogicalSortExchange(distribution=[hash], collation=[[]], isSortOnSender=[false], isSortOnReceiver=[false])
LogicalSort(fetch=[100])
PinotLogicalAggregate(group=[{0, 1}])
PinotLogicalExchange(distribution=[hash[0, 1]])
PinotLogicalAggregate(group=[{0, 2}])
LogicalJoin(condition=[=($1, $3)], joinType=[inner])
PinotLogicalExchange(distribution=[hash[1]])
LeafStageCombineOperator(table=[userAttributes])
StreamingInstanceResponse
StreamingCombineSelect
SelectStreaming(table=[userAttributes], totalDocs=[10000])
Project(columns=[[deviceOS, userUUID]])
DocIdSet(maxDocs=[40000])
FilterMatchEntireSegment(numDocs=[10000])
PinotLogicalExchange(distribution=[hash[1]])
LeafStageCombineOperator(table=[userGroups])
StreamingInstanceResponse
StreamingCombineSelect
SelectStreaming(table=[userGroups], totalDocs=[2478])
Project(columns=[[groupUUID, userUUID]])
DocIdSet(maxDocs=[50000])
FilterInvertedIndex(predicate=[groupUUID = 'group-1'], indexLookUp=[inverted_index], operator=[EQ])
SelectStreaming(segment=[userGroups_OFFLINE_4], table=[userGroups], totalDocs=[4])
Project(columns=[[groupUUID, userUUID]])
DocIdSet(maxDocs=[10000])
FilterEmpty
SelectStreaming(segment=[userGroups_OFFLINE_6], table=[userGroups], totalDocs=[4])
Project(columns=[[groupUUID, userUUID]])
DocIdSet(maxDocs=[10000])
FilterMatchEntireSegment(numDocs=[4])
Logical Plan
논리 계획은 쿼리 실행 계획의 고수준 표현이에요. 이 계획은 서버에 세그먼트 특정 계획을 묻지 않고 broker에서 계산돼요. 즉 논리 계획에는 데이터 분포, 인덱스 등 세그먼트 특정 정보가 포함되지 않아요.
Pinot 1.3.0에서 논리 계획은 기본적으로 활성화되어 있으며 EXPLAIN PLAN FOR 구문을 사용해 얻을 수 있어요. 선택적으로 세그먼트 계획을 기본값으로 활성화할 수 있으며, 그 경우 EXPLAIN PLAN WITHOUT IMPLEMENTATION FOR 구문을 사용해 논리 계획을 얻을 수 있어요.
EXPLAIN PLAN FOR는 기본적으로 논리 계획을 렌더링하지만, Pinot는 또한 쿼리가 실행 가능한 계획으로 변환될 수 있는지 검증해요. 따라서 현재 라우팅 상태에 의존하는 오류를 포함해 쿼리 실행이 직면하게 될 계획 오류를 반환해요. 구현 계획 없이 논리 계획만을 특별히 필요로 할 때, 현재는 실행할 수 없는 쿼리를 포함해 EXPLAIN PLAN WITHOUT IMPLEMENTATION FOR를 사용해요.
참고: 논리 계획을 요청하는 권장 방법은
EXPLAIN PLAN WITHOUT IMPLEMENTATION FOR를 사용하는 것이에요. 이 구문은 구성과 무관하게 모든 Pinot 버전에서 사용할 수 있기 때문이에요.
예시:
-- WITHOUT IMPLENTATION qualifier can be used to ensure logical plan is used
-- It can be used in any version of Pinot even when segment plan is enabled by default
EXPLAIN PLAN WITHOUT IMPLEMENTATION FOR
SELECT DISTINCT deviceOS, groupUUID
FROM userAttributes AS a
JOIN userGroups AS g
ON a.userUUID = g.userUUID
WHERE g.groupUUID = 'group-1'
LIMIT 100
반환:
Execution Plan
LogicalSort(offset=[0], fetch=[100])
PinotLogicalSortExchange(distribution=[hash], collation=[[]], isSortOnSender=[false], isSortOnReceiver=[false])
LogicalSort(fetch=[100])
PinotLogicalAggregate(group=[{0, 1}])
PinotLogicalExchange(distribution=[hash[0, 1]])
PinotLogicalAggregate(group=[{0, 2}])
LogicalJoin(condition=[=($1, $3)], joinType=[inner])
PinotLogicalExchange(distribution=[hash[1]])
LogicalProject(deviceOS=[$4], userUUID=[$6])
LogicalTableScan(table=[[default, userAttributes]])
PinotLogicalExchange(distribution=[hash[1]])
LogicalProject(groupUUID=[$3], userUUID=[$4])
LogicalFilter(condition=[=($3, _UTF-8'group-1')])
LogicalTableScan(table=[[default, userGroups]])
Workers plan
참고: 이 explain 모드의 이름을 어떻게 지을 것인지에 대한 논의가 있었으며 향후 버전에서 변경될 수 있어요. worker라는 용어는 사용자 문서의 다른 곳에서는 설명되지 않는 구현 세부 정보를 드러내요.
workers plan은 쿼리가 서로 다른 서버와 그 안의 workers에게 어떻게 분산되는지에 대한 정보를 포함하는 쿼리 실행 계획의 상세한 표현이에요. 이 계획에는 데이터 분포, 인덱스 등 세그먼트 특정 정보가 포함되지 않으며, 일반적인 사용 사례에서는 계획 중 가장[^4] 덜 유용할 수 있어요.
주요 사용 사례는 예를 들어 조인이 colocated 방식으로 실행되는지 검증함으로써 workers 간 데이터 셔플링을 줄이려는 것이에요.
예시
EXPLAIN IMPLEMENTATION PLAN FOR
SELECT DISTINCT deviceOS, groupUUID
FROM userAttributes AS a
JOIN userGroups AS g
ON a.userUUID = g.userUUID
WHERE g.groupUUID = 'group-1'
LIMIT 100
반환:
0]@192.168.0.98:54196|[0] MAIL_RECEIVE(BROADCAST_DISTRIBUTED)
├── [1]@192.168.0.98:54227|[3] MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@192.168.0.98:54196|[0]} (Subtree Omitted)
├── [1]@192.168.0.98:54220|[2] MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@192.168.0.98:54196|[0]} (Subtree Omitted)
├── [1]@192.168.0.98:54214|[1] MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@192.168.0.98:54196|[0]} (Subtree Omitted)
└── [1]@192.168.0.98:54206|[0] MAIL_SEND(BROADCAST_DISTRIBUTED)->{[0]@192.168.0.98:54196|[0]}
└── [1]@192.168.0.98:54206|[0] SORT LIMIT 100
└── [1]@192.168.0.98:54206|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)
├── [2]@192.168.0.98:54227|[3] MAIL_SEND(HASH_DISTRIBUTED)->{[1]@192.168.0.98:54207|[0],[1]@192.168.0.98:54215|[1],[1]@192.168.0.98:54221|[2],[1]@192.168.0.98:54228|[3]} (Subtree Omitted)
├── [2]@192.168.0.98:54220|[2] MAIL_SEND(HASH_DISTRIBUTED)->{[1]@192.168.0.98:54207|[0],[1]@192.168.0.98:54215|[1],[1]@192.168.0.98:54221|[2],[1]@192.168.0.98:54228|[3]} (Subtree Omitted)
├── [2]@192.168.0.98:54214|[1] MAIL_SEND(HASH_DISTRIBUTED)->{[1]@192.168.0.98:54207|[0],[1]@192.168.0.98:54215|[1],[1]@192.168.0.98:54221|[2],[1]@192.168.0.98:54228|[3]} (Subtree Omitted)
└── [2]@192.168.0.98:54206|[0] MAIL_SEND(HASH_DISTRIBUTED)->{[1]@192.168.0.98:54207|[0],[1]@192.168.0.98:54215|[1],[1]@192.168.0.98:54221|[2],[1]@192.168.0.98:54228|[3]}
└── [2]@192.168.0.98:54206|[0] SORT LIMIT 100
└── [2]@192.168.0.98:54206|[0] AGGREGATE_FINAL
└── [2]@192.168.0.98:54206|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)
├── [3]@192.168.0.98:54227|[3] MAIL_SEND(HASH_DISTRIBUTED)->{[2]@192.168.0.98:54207|[0],[2]@192.168.0.98:54215|[1],[2]@192.168.0.98:54221|[2],[2]@192.168.0.98:54228|[3]} (Subtree Omitted)
├── [3]@192.168.0.98:54220|[2] MAIL_SEND(HASH_DISTRIBUTED)->{[2]@192.168.0.98:54207|[0],[2]@192.168.0.98:54215|[1],[2]@192.168.0.98:54221|[2],[2]@192.168.0.98:54228|[3]} (Subtree Omitted)
├── [3]@192.168.0.98:54214|[1] MAIL_SEND(HASH_DISTRIBUTED)->{[2]@192.168.0.98:54207|[0],[2]@192.168.0.98:54215|[1],[2]@192.168.0.98:54221|[2],[2]@192.168.0.98:54228|[3]} (Subtree Omitted)
└── [3]@192.168.0.98:54206|[0] MAIL_SEND(HASH_DISTRIBUTED)->{[2]@192.168.0.98:54207|[0],[2]@192.168.0.98:54215|[1],[2]@192.168.0.98:54221|[2],[2]@192.168.0.98:54228|[3]}
└── [3]@192.168.0.98:54206|[0] AGGREGATE_LEAF
└── [3]@192.168.0.98:54206|[0] JOIN
├── [3]@192.168.0.98:54206|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)
│ ├── [4]@192.168.0.98:54227|[1] MAIL_SEND(HASH_DISTRIBUTED)->{[3]@192.168.0.98:54207|[0],[3]@192.168.0.98:54215|[1],[3]@192.168.0.98:54221|[2],[3]@192.168.0.98:54228|[3]} (Subtree Omitted)
│ └── [4]@192.168.0.98:54214|[0] MAIL_SEND(HASH_DISTRIBUTED)->{[3]@192.168.0.98:54207|[0],[3]@192.168.0.98:54215|[1],[3]@192.168.0.98:54221|[2],[3]@192.168.0.98:54228|[3]}
│ └── [4]@192.168.0.98:54214|[0] PROJECT
│ └── [4]@192.168.0.98:54214|[0] TABLE SCAN (userAttributes) null
└── [3]@192.168.0.98:54206|[0] MAIL_RECEIVE(HASH_DISTRIBUTED)
├── [5]@192.168.0.98:54227|[1] MAIL_SEND(HASH_DISTRIBUTED)->{[3]@192.168.0.98:54207|[0],[3]@192.168.0.98:54215|[1],[3]@192.168.0.98:54221|[2],[3]@192.168.0.98:54228|[3]} (Subtree Omitted)
└── [5]@192.168.0.98:54214|[0] MAIL_SEND(HASH_DISTRIBUTED)->{[3]@192.168.0.98:54207|[0],[3]@192.168.0.98:54215|[1],[3]@192.168.0.98:54221|[2],[3]@192.168.0.98:54228|[3]}
└── [5]@192.168.0.98:54214|[0] PROJECT
└── [5]@192.168.0.98:54214|[0] FILTER
└── [5]@192.168.0.98:54214|[0] TABLE SCAN (userGroups) null
multi-stage explain 계획 해석
multi-stage 계획은 single-stage 계획보다 더 복잡해요. 이 섹션에서는 이를 해석하는 방법을 설명해요.
EXPLAIN PLAN 구문을 사용해 쿼리의 논리 계획을 얻을 수 있어요. 출력 형식은 다양하지만 모두 쿼리의 논리 계획을 나타내요.
쿼리:
explain plan for
select customer.c_address, orders.o_shippriority
from customer
join orders
on customer.c_custkey = orders.o_custkey
limit 10
다음 출력을 만들 수 있어요:
LogicalSort(offset=[0], fetch=[10])
PinotLogicalSortExchange(distribution=[hash], collation=[[]], isSortOnSender=[false], isSortOnReceiver=[false])
LogicalSort(fetch=[10])
LogicalProject(c_address=[$0], o_shippriority=[$3])
LogicalJoin(condition=[=($1, $2)], joinType=[inner])
PinotLogicalExchange(distribution=[hash[1]])
LogicalProject(c_address=[$4], c_custkey=[$6])
LogicalTableScan(table=[[default, customer]])
PinotLogicalExchange(distribution=[hash[0]])
LogicalProject(o_custkey=[$5], o_shippriority=[$10])
LogicalTableScan(table=[[default, orders]])
트리의 각 노드는 하나의 연산을 나타내며, 각 연산자는 속성(attribute)을 가져요. 예를 들어 LogicalJoin 연산자는 조인 조건을 지정하는 condition 속성과 joinType을 가져요.
인덱스 참조 이해하기
$2 같은 표현식은 각 연산자에 대한 입력 행으로의 인덱스 참조예요. 이를 이해하려면 연산자의 자식들을 보고 어떤 속성이 참조되는지 확인해요. 보통 잎 연산자에서 시작해요.
예를 들어 LogicalTableScan은 항상 테이블의 전체 행을 반환하므로 그 속성은 테이블의 컬럼들이에요:
PinotLogicalExchange(distribution=[hash[0]])
LogicalProject(o_custkey=[$5], o_shippriority=[$10])
LogicalTableScan(table=[[default, orders]])
LogicalProject 연산자는 컬럼 o_custkey와 o_shippriority(테이블 행의 $5와 $10 위치)를 선택하고 두 컬럼으로 된 행을 생성해요. PinotLogicalExchange는 hash[0]을 사용해 행을 분산하는데, 이는 LogicalProject의 첫 번째 컬럼인 o_custkey의 해시를 의미해요.
조인의 가상 행
LogicalJoin 연산자는 두 업스트림 스테이지에서 행을 받아요. 조인이 보는 가상 행은 왼쪽 + 오른쪽의 연결(concatenation)이에요.
위 예시에서 왼쪽 스테이지는 [c_address, c_custkey]를 보내고 오른쪽 스테이지는 [o_custkey, o_shippriority]를 보내요. 조인은 [c_address, c_custkey, o_custkey, o_shippriority] 컬럼의 행을 봐요. 조건 =($1, $2)는 c_custkey와 o_custkey로 조인해요. 조인은 모든 컬럼을 변경하지 않고 통과시키므로, 아래의 LogicalProject가 $0과 $3을 선택해 [c_address, o_shippriority]를 만들어요.
ORDER BY 없는 LogicalSort
SQL 쿼리에 ORDER BY가 없어도 LogicalSort 연산자가 나타날 수 있어요. 관계 대수에서 정렬 노드는 LIMIT을 표현하는 데 사용돼요. 정렬 조건이 지정되지 않으면 실제 정렬은 수행되지 않고 행 제한만 적용돼요.
single stage 쿼리 엔진에 대한 Explain
참고: single stage 쿼리 엔진에 대한 explain plan은 explain-plan.md에 자세히 설명되어 있어요.
single stage 쿼리 엔진에 대한 explain plan은 더 단순하고 덜 커스터마이즈되지만, 정보를 표 형식으로 반환해요. 예를 들어 쿼리 EXPLAIN PLAN FOR SELECT playerID, playerName FROM baseballStats.
다음 표를 반환해요:
+---------------------------------------------|------------|---------|
| Operator | Operator_Id|Parent_Id|
+---------------------------------------------|------------|---------|
|BROKER_REDUCE(limit:10) | 1 | 0 |
|COMBINE_SELECT | 2 | 1 |
|PLAN_START(numSegmentsForThisPlan:1) | -1 | -1 |
|SELECT(selectList:playerID, playerName) | 3 | 2 |
|TRANSFORM_PASSTHROUGH(playerID, playerName) | 4 | 3 |
|PROJECT(playerName, playerID) | 5 | 4 |
|DOC_ID_SET | 6 | 5 |
|FILTER_MATCH_ENTIRE_SEGMENT(docs:97889) | 7 | 6 |
+---------------------------------------------|------------|---------|
Operator 컬럼은 Pinot가 실행할 연산자를 설명하는 반면, Operator_Id와 Parent_Id 컬럼은 연산자 간 부모-자식 관계를 보여주며, 이는 실행 트리를 형성해요. 예를 들어 위 계획은 다음과 같이 이해해야 해요:
BROKER_REDUCE(limit:10)
└── COMBINE_SELECT
└── PLAN_START(numSegmentsForThisPlan:1)
└── SELECT(selectList:playerID, playerName)
└── TRANSFORM_PASSTHROUGH(playerID, playerName)
└── PROJECT(playerName, playerID)
└── DOC_ID_SET
└── FILTER_MATCH_ENTIRE_SEGMENT(docs:97889)
[^1]: '다른 쿼리 계획' ? [^2]: 집중된(focused)? [^3]: 다른 계획들? [^4]: 가장 적게(least)?