텍스트 검색 분석
텍스트 검색 분석 (Text Search Analytics)
Apache Pinot에서 전체 텍스트 검색과 OLAP 집계를 결합하는 전체 가이드예요. 이 플레이북은 전체 텍스트 검색과 OLAP 스타일 집계를 함께 쓰는 워크로드를 다뤄요 — 로그에서 오류 패턴을 검색한 뒤 서비스별로 집계하거나, 제품 카탈로그를 설명 텍스트로 필터링한 뒤 판매량으로 순위를 매기거나, 지원 티켓을 키워드로 분류(triage)한 뒤 심각도별로 그룹화하는 경우요.
출처: 문서
본문
이 패턴을 언제 쓸까 (When to use this pattern)
다음과 같은 경우 이 플레이북을 사용해요:
- 쿼리에 자유 텍스트 조건(키워드 검색, 구문 일치, 퍼지 일치) 그리고 구조화된 필터/집계(GROUP BY, SUM, COUNT, 시간 범위 필터)가 모두 포함돼요.
- Elasticsearch와 별도 OLAP 저장소를 따로 유지하는 대신 텍스트 검색과 분석용 단일 시스템을 원해요.
- 텍스트 컬럼이 짧은 범주형 값이 아니라 자연어(로그 메시지, 제품 설명, 티켓 본문, 사용자 리뷰)를 담아요.
- 텍스트 데이터를 실시간으로 적재하고 즉시 검색 가능해야 해요.
텍스트 컬럼이 짧고 저카디널리티 라벨(예: 상태 코드, 국가명)이라면 표준 역인덱스로 충분해요 — 텍스트 인덱스가 필요 없어요.
아키텍처 스케치 (Architecture sketch)
Log / event stream ──▶ Kafka ──▶ Pinot REALTIME table
│
┌───────┴────────┐
│ Servers with │
│ text index on │
│ message column │
└────────────────┘
│
TEXT_MATCH + OLAP
queries from app
Pinot은 두 가지 텍스트 인덱스 구현을 지원해요:
| 인덱스 | 엔진 | 가장 적합한 경우 |
|---|---|---|
| 텍스트 인덱스 (Lucene 기반) | Apache Lucene | 전체 Lucene 쿼리 문법: 구문 쿼리, 퍼지, 정규식, 와일드카드, 근접(proximity) |
| 네이티브 텍스트 인덱스 | Pinot 내장 | 단순 키워드/구문 검색, 더 낮은 메모리와 더 빠른 적재 |
이 플레이북은 두 가지를 모두 다뤄요. 단순한 사용 사례는 네이티브 텍스트 인덱스로 시작하고, 고급 쿼리 문법이 필요하면 Lucene 기반으로 전환하세요.
스키마 (Schema)
{
"schemaName": "support_tickets",
"dimensionFieldSpecs": [
{ "name": "ticketId", "dataType": "STRING" },
{ "name": "customerId", "dataType": "STRING" },
{ "name": "severity", "dataType": "STRING" },
{ "name": "service", "dataType": "STRING" },
{ "name": "assignee", "dataType": "STRING" },
{ "name": "subject", "dataType": "STRING" },
{ "name": "body", "dataType": "STRING" }
],
"metricFieldSpecs": [
{ "name": "responseTimeMs", "dataType": "LONG" }
],
"dateTimeFieldSpecs": [
{
"name": "createdAt",
"dataType": "TIMESTAMP",
"format": "1:MILLISECONDS:EPOCH",
"granularity": "1:MILLISECONDS"
}
]
}
subject와 body 컬럼이 텍스트 인덱스를 담당해요. 이들은 STRING 타입으로 선언해야 하고 사전(dictionary) 인코딩을 하지 않아야 해요(noDictionaryColumns 목록에 넣어요).
Lucene 기반 텍스트 인덱스가 있는 테이블 구성
{
"tableName": "support_tickets",
"tableType": "REALTIME",
"segmentsConfig": {
"timeColumnName": "createdAt",
"retentionTimeUnit": "DAYS",
"retentionTimeValue": "180",
"replication": "2"
},
"tableIndexConfig": {
"loadMode": "MMAP",
"invertedIndexColumns": ["severity", "service", "assignee"],
"rangeIndexColumns": ["createdAt"],
"noDictionaryColumns": ["ticketId", "customerId", "subject", "body"],
"bloomFilterColumns": ["ticketId"],
"fieldConfigList": [
{
"name": "subject",
"encodingType": "RAW",
"indexTypes": ["TEXT"],
"properties": {
"fstType": "NATIVE"
}
},
{
"name": "body",
"encodingType": "RAW",
"indexTypes": ["TEXT"],
"properties": {
"fstType": "NATIVE"
}
}
],
"streamConfigs": {
"streamType": "kafka",
"stream.kafka.topic.name": "support-tickets",
"stream.kafka.broker.list": "kafka:9092",
"stream.kafka.consumer.factory.class.name": "org.apache.pinot.plugin.stream.kafka30.KafkaConsumerFactory",
"stream.kafka.decoder.class.name": "org.apache.pinot.plugin.stream.kafka.KafkaJSONMessageDecoder",
"realtime.segment.flush.threshold.rows": "250000",
"realtime.segment.flush.threshold.time": "4h"
}
},
"tenants": {
"broker": "DefaultTenant",
"server": "DefaultTenant"
},
"metadata": {}
}
구성 요점 (Configuration highlights)
| 설정 | 이유 |
|---|---|
indexTypes: ["TEXT"]가 있는 fieldConfigList |
subject와 body에 Lucene 기반 텍스트 인덱스를 생성해요 |
encodingType: RAW |
텍스트 인덱스 컬럼은 사전이 아닌 raw 인코딩을 사용해야 해요 |
noDictionaryColumns에 텍스트 컬럼 포함 |
긴 텍스트에 비효율적인 사전 인코딩을 비활성화해요 |
구조화 컬럼의 invertedIndexColumns |
비텍스트 필터(severity, service)용 표준 역인덱스 |
대안: 네이티브 텍스트 인덱스 (Alternative: native text index)
리소스 오버헤드가 낮은 단순한 검색 요구(키워드 일치, 구문 일치)라면:
"fieldConfigList": [
{
"name": "body",
"encodingType": "RAW",
"indexTypes": ["NATIVE_TEXT"]
}
]
네이티브 텍스트 인덱스는 Lucene을 사용하지 않아 메모리 오버헤드가 낮지만, 퍼지 일치, 정규식, 근접 쿼리, 부스팅(boosting)을 지원하지 않아요. 비교는 Native Text Index를 참고하세요.
쿼리 패턴 (Query patterns)
TEXT_MATCH: 키워드 검색과 집계
"timeout"을 언급하는 티켓을 찾아 서비스별로 집계해요:
SELECT
service,
severity,
COUNT(*) AS ticket_count,
AVG(responseTimeMs) AS avg_response_time
FROM support_tickets
WHERE TEXT_MATCH(body, 'timeout')
AND createdAt > ago('P7D')
GROUP BY service, severity
ORDER BY ticket_count DESC
LIMIT 50
구문 검색 (Phrase search)
"connection refused"라는 정확한 구문이 있는 티켓을 찾아요:
SELECT ticketId, subject, createdAt
FROM support_tickets
WHERE TEXT_MATCH(body, '"connection refused"')
AND severity = 'P1'
ORDER BY createdAt DESC
LIMIT 20
불리언 텍스트 쿼리
텍스트 조건을 AND, OR, NOT으로 결합해요:
SELECT ticketId, subject, service
FROM support_tickets
WHERE TEXT_MATCH(body, '(timeout OR "connection refused") AND NOT retry')
AND createdAt > ago('P30D')
LIMIT 100
와일드카드와 퍼지 검색 (Lucene 인덱스만)
-- Wildcard: "authenticate", "authentication", "authenticator" 일치
SELECT ticketId, subject
FROM support_tickets
WHERE TEXT_MATCH(body, 'authenticat*')
LIMIT 50
-- Fuzzy: "recieve", "receive", "receieve" 일치 (편집 거리 2)
SELECT ticketId, subject
FROM support_tickets
WHERE TEXT_MATCH(body, 'receive~2')
LIMIT 50
텍스트 검색과 OLAP 집계 결합
이 패턴의 힘은 텍스트 필터를 더 큰 분석 쿼리의 일부로 실행하는 데 있어요:
SELECT
DATETRUNC('day', createdAt, 'MILLISECONDS') AS day,
COUNT(*) AS error_tickets,
PERCENTILEEST(responseTimeMs, 95) AS p95_response
FROM support_tickets
WHERE TEXT_MATCH(body, '"database error" OR "query timeout"')
AND severity IN ('P1', 'P2')
AND createdAt > ago('P14D')
GROUP BY day
ORDER BY day
LIMIT 100
참고:
TEXT_MATCH는 조건(predicate)이지 점수 함수가 아니에요. Pinot은 관련성 점수를 반환하지 않아요. 순위가 매겨진 검색 결과가 필요하면 순위 산출용 외부 검색 엔진을 유지하고 Pinot은 분석 집계 레이어로만 사용하세요.
로그 분석 변형 (Log analytics variant)
로그 분석(예: Apache 접근 로그, 애플리케이션 로그)의 경우 스키마는 보통 logMessage 텍스트 컬럼과 적재 시점에 추출된 구조화 컬럼을 가져요:
{
"schemaName": "app_logs",
"dimensionFieldSpecs": [
{ "name": "service", "dataType": "STRING" },
{ "name": "level", "dataType": "STRING" },
{ "name": "host", "dataType": "STRING" },
{ "name": "logMessage", "dataType": "STRING" }
],
"dateTimeFieldSpecs": [
{
"name": "logTimestamp",
"dataType": "TIMESTAMP",
"format": "1:MILLISECONDS:EPOCH",
"granularity": "1:MILLISECONDS"
}
]
}
적재 중에 ingestion transformations으로 로그 줄에서 구조화 필드를 추출하고, 원본 logMessage에 텍스트 인덱스를 적용해 임시(ad-hoc) 검색에 사용하세요.
고카디널리티 로그 데이터에는 압축 로그 저장과 효율적인 검색을 제공하는 Stream Ingestion with CLP를 고려해요.
운영 체크리스트 (Operational checklist)
서비스 시작 전
- 텍스트 인덱스 컬럼이
noDictionaryColumns에 있고fieldConfigList에서encodingType: RAW를 쓰는지 확인하세요. 텍스트 컬럼의 사전 인코딩은 메모리를 낭비하고 텍스트 인덱싱을 망가뜨려요. - Lucene 인덱스용으로 서버에 추가 힙을 할당하세요. Lucene 텍스트 인덱스마다 세그먼트별 인메모리 데이터 구조를 유지해요. 텍스트 인덱스가 없는 비슷한 테이블보다 힙을 20–30% 더 예산에 잡으세요.
- 현실적인 텍스트 쿼리로 쿼리 지연을 테스트하세요. 짧은 키워드의
TEXT_MATCH는 빠르지만, 큰 텍스트 컬럼의 와일드카드 쿼리는 느릴 수 있어요. - 텍스트 비중이 높은 세그먼트는 행당 바이트가 더 크므로
realtime.segment.flush.threshold.rows를 평소보다 낮게(예: 250K) 설정하세요.
모니터링
- 디스크의 Lucene 인덱스 크기: 텍스트 인덱스는 원본 텍스트 데이터 크기의 1~3배일 수 있어요. 서버별 디스크 사용량을 모니터링하세요.
- TEXT_MATCH 쿼리의 쿼리 지연: Lucene 쿼리 실행 시간이 서버 측 쿼리 메트릭에 포함돼요. P99가 급증하면 비싼 와일드카드나 정규식 패턴을 확인하세요.
- 세그먼트 flush 시간: 세그먼트 flush 중 텍스트 인덱스 빌드가 표준 인덱스보다 오래 걸려요. flush 시간이 늘면
flush.threshold.rows를 줄이세요.
흔한 함정 (Common pitfalls)
| 함정 | 해결책 |
|---|---|
TEXT_MATCH가 결과를 반환하지 않음 |
컬럼에 텍스트 인덱스가 구성됐는지(역인덱스만이 아닌) 확인하세요. fieldConfigList를 확인하세요 |
| 서버 메모리 사용량이 높음 | Lucene 인덱스는 메모리 집약적이에요. 키워드/구문 검색만 필요하면 네이티브 텍스트 인덱스를 사용하세요 |
| 선행 와일드카드가 있는 느린 와일드카드 쿼리 | 선행 와일드카드(*error)는 전체 용어 사전을 스캔해야 해요. 피하거나, 접두사 쿼리에 FST 인덱스를 사용하세요 |
| 저카디널리티 컬럼의 텍스트 인덱스 | 표준 역인덱스를 대신 사용하세요. 고유 값이 적은 컬럼에는 텍스트 인덱스가 과합니다 |
| 검색 관련성 순위가 필요함 | Pinot의 TEXT_MATCH는 필터이지 점수기가 아니에요. 관련성 순위 검색에는 Elasticsearch 등을 사용하고 Pinot은 집계 레이어로 쓰세요 |