Ken
한국 SaaS 커머스 플랫폼의 고객지원 콘솔. 상담사가 검색창에결제 실패를 친다 — 0.2초 안에 100만 티켓 중 관련된 47,000건이 떠야 한다. 그런데 같은 쿼리를 영어 데이터 가정으로 만든 인덱스 위에 그대로 올리면 recall이 반토막이 난다. 한국어는 별도의 형태소 분석기가 없는 ClickHouse에서 조사·띄어쓰기·복합명사가 그대로 검색 품질에 새어든다.이 글은 한국어/영어 혼용 티켓 데이터셋 위에 ClickHouse가 제공하는 3가지 인덱스 전략(
tokenbf_v1,ngrambf_v1, 26.2에서 GA된text역인덱스)을 깔고, 실제 상담팀이 매일 돌리는 쿼리 패턴 7가지를 풀어낸다. 실측은 ClickHouse Cloud 26.2.1 / 200K 티켓에서 수행했다.
1. 시나리오 — ClickStore의 검색이 풀어야 할 문제
가상 한국 e-커머스 플랫폼 ClickStore의 CS 조직이 처리하는 티켓 패턴:
- 분기당 100만 건 유입, 월별 파티션
- 언어 비율: 한국어 70% / 한+영 혼용 20% / 영어 10%
- 본문은
subject + body + resolution + tags, 본문에는 흔하게: 결제가 안돼요,로그인이 안 됩니다같은 조사 변형PAYMENT_DECLINED 발생,ERROR 502같은 영어 에러 코드 혼용iPhone 15 Pro 배송 지연,Galaxy Watch 환불같은 한+영 단어 경계
상담팀이 검색 위에 올리는 7가지 워크로드:
쉽게 말해 상담사가 타이핑하는 검색은 sub-200ms로, 분석/대시보드는 풀스캔 없이 동작해야 한다. 그러려면 어떤 인덱스를 깔아야 하는가가 이 글의 질문이다.
2. 3가지(+ 하나) 인덱스 전략
ClickHouse가 풀텍스트 검색에 제공하는 skip index는 세 부류 — 우리 실습에서는 동일 스키마의 4벌 테이블에 각각 다른 전략을 적용해 사과 대 사과로 비교했다.
26.2에서 일어난 변화 두 가지:
text인덱스가 GA됐다.allow_experimental_full_text_index설정은 obsolete가 됐고 별도 활성화 없이 바로 쓸 수 있다.- 공식 권장사항이 바뀌었다 —
tokenbf_v1,ngrambf_v1은 deprecated다 (작동은 함). 새 워크로드에는text(...)를 쓰는 게 권장된다.
2.1 tokenizer 선택 — asciiCJK의 함정
text 인덱스의 핵심 파라미터는 tokenizer다. 공식 문서가 한/영 혼용에 권장하는 것은 asciiCJK — Unicode word boundary를 따르고 CJK 문자를 단일 토큰화한다.
그러나 실측 환경(ClickHouse Cloud 26.2.1.390)에서는 asciiCJK가 system.tokenizers에 노출되지 않아 사용 불가였다.
SELECT name FROM clusterAllReplicas(default, system.tokenizers);
-- ngrams, splitByNonAlpha, sparseGrams, tokenbf_v1, ngrambf_v1,
-- array, splitByString, sparse_grams
-- → asciiCJK 없음빌드/배포 채널 차이로 보이는데, 프로덕션 도입 전에는 사용 중인 클러스터에서 반드시 system.tokenizers를 확인할 것. 이 글의 측정은 차선책인 splitByNonAlpha로 진행했다. 한국어 토큰화 거동은 tokenbf_v1(공백 토크나이저)와 거의 동일하므로 한국어 한계 분석에는 영향이 없다.
또 하나의 운영 제약: 한 컬럼당 text 인덱스는 하나만 만들 수 있다.
DB::Exception: Column `body` must not have more than one text index.splitByNonAlpha와 ngrams(2)를 같은 body 컬럼에 동시에 걸려고 하면 위 에러가 난다. 둘 다 필요하면 컬럼을 복제하거나 테이블을 분리해야 한다.
3. 인덱스 저장 비용 — 데이터보다 더 큰 인덱스
가장 충격적인 첫 수치다. 200K 티켓, 한/영 혼용 본문, 동일 데이터:
SELECT table, sum(rows) AS rows,
formatReadableSize(sum(data_compressed_bytes)) AS data,
formatReadableSize(sum(secondary_indices_compressed_bytes)) AS index_size,
formatReadableSize(sum(bytes_on_disk)) AS total
FROM system.parts
WHERE database = 'support_search' AND active AND table LIKE 'tickets_%'
GROUP BY table ORDER BY table;테이블 | 인덱스 전략 | 본문 데이터 | 인덱스 크기 | 디스크 합계 | 인덱스 / 본문 |
tickets_noidx | 없음 | 4.83 MiB | — | 4.84 MiB | 0% |
tickets_token | tokenbf_v1 ×2 | 4.83 MiB | 17.76 KiB | 4.86 MiB | 0.4% |
tickets_ngram | ngrambf_v1 ×2 | 4.83 MiB | 51.47 KiB | 4.89 MiB | 1.0% |
tickets_text | text(splitByNonAlpha) ×2 | 4.83 MiB | 5.55 MiB | 10.40 MiB | 115% |
tickets_text_ngram | text(ngrams(2)) ×1 | 4.83 MiB | 14.69 MiB | 19.53 MiB | 304% |
읽는 순간 두 가지가 즉시 보인다.
- 블룸필터는 사실상 공짜다. 본문의 1% 미만. 이게 deprecated된 이유는 비용이 비싸서가 아니라 기능이 빈약해서다 (
hasAllTokens,hasPhrase, posting list 등이 없음). - text 인덱스는 본문보다 더 크다.
splitByNonAlpha로 무려 1.15×,ngrams(2)로 3×. 디스크 예산을 두 배로 잡아야 한다.
ngrams(2)가 비대해지는 이유는 단순하다 — 한국어 2글자 bigram은 카디널리티가 매우 높고, 각 토큰마다 posting list가 따로 생긴다. 이 인덱스는 짧은 substring 검색이 매출과 직결될 때만 정당화된다.
제언: 기본은text(asciiCJK)또는 환경상 그게 안 되면text(splitByNonAlpha).ngrams(N)은 유즈케이스가 명확할 때만 별도 컬럼/테이블에 추가하라.
4. 영어 쿼리는 모두 OK — 한국어가 진짜 시험대
영어 정확 토큰 검색은 어떤 인덱스로도 잘 된다. PAYMENT_DECLINED를 본문에 포함한 티켓 수를 4개 테이블에 동일 쿼리로:
방법 | hit 수 | 비고 |
tickets_noidx + LIKE '%PAYMENT_DECLINED%' | 31,474 | ground truth |
tickets_token + hasToken(body, 'PAYMENT') | 31,474 | ※ |
tickets_ngram + LIKE '%PAYMENT_DECLINED%' | 31,474 | |
tickets_text + hasAllTokens(body, ['PAYMENT','DECLINED']) | 31,474 |
※ 운영상 함정 하나 — hasToken(body, 'PAYMENT_DECLINED')을 그대로 쓰면 에러가 난다.
DB::Exception: Needle must not contain whitespace or separator characters_(underscore)는 토큰 separator이기 때문에 needle 안에 들어갈 수 없다. 같은 문자열을 찾으려면 hasToken('PAYMENT')을 쓰거나, 둘 다 매치해야 한다면 hasAllTokens(['PAYMENT','DECLINED']). 검색창에서 입력값을 그대로 needle로 던지는 코드는 separator 분리 전처리를 거쳐야 한다.
한국어 2글자 — 결제
같은 쿼리의 한국어 버전. ground truth는 positionUTF8(body, '결제') > 0로 풀스캔해 얻은 47,368건.
방법 | hit 수 | Recall |
tickets_noidx + positionUTF8 | 47,368 | 100% (기준) |
tickets_token + hasToken('결제') | 23,489 | 49.6% ⚠ |
tickets_ngram + LIKE '%결제%' | 47,368 | 100% |
tickets_text + hasToken('결제') | 23,489 | 49.6% ⚠ |
tickets_text + LIKE '%결제%' | 47,368 | 100% |
tickets_text_ngram + LIKE '%결제%' | 47,368 | 100% |
한 줄 요약: 한국어에서 hasToken은 절반을 놓친다. 이유는 단순하다.
조사(가, 는, 를, 도, 에, 의…)가 명사에 직접 붙기 때문에 공백 토크나이저는 결제가, 결제는, 결제를, 결제 실패를 모두 다른 토큰으로 본다. hasToken('결제')은 "결제 "라는 공백 분리된 단독 토큰만 잡고 나머지 절반 이상을 놓친다.
해결책 두 가지가 공존해야 한다.
- 인덱스 측 —
LIKE로 풀어 substring 매치. text 인덱스(splitByNonAlpha)에서LIKE '%결제%'는 토큰 매치가 아니라 inverted index의 dictionary 탐색을 거쳐 정상 recall이 나온다. ngrambf_v1에서도 OK. - 클라이언트 측 — 조사 변형 OR 확장.
결제검색을 자동으로['결제', '결제가', '결제는', '결제를', '결제도', '결제 실패']로 펼쳐hasAnyTokens/multiSearchAny로 던지는 정규화 레이어. 이건 어떤 인덱스를 쓰든 필요하다.
띄어쓰기·어간·복합명사
조사만 문제가 아니다. 한국어 검색의 4대 함정:
카테고리 | 예시 | 해결 |
조사 | 결제 vs 결제가/는/를/도 | LIKE 또는 OR 확장 |
띄어쓰기 | 로그인이 안 됩니다 vs 로그인이 안됩니다 vs 로그인안됨 | OR 확장 + ngrams 백업 |
어간/어미 | 안돼요 / 안 됩니다 / 안 됨 / 안돼 | OR 확장 (사전 정의) |
복합명사 | 주문번호 vs 주문 번호 | 둘 다 OR로 |
ClickHouse에 한국어 형태소 분석기가 내장돼 있지 않기 때문에 이 함정들은 모두 클라이언트 정규화 레이어에서 막아야 한다. 한국어 NLP가 필수라면 외부에서 mecab-ko/KoNLPy로 토큰화해 별도 컬럼에 저장한 뒤 text(tokenizer = array) 또는 tokenizer = splitByString을 거는 게 가장 견고하다.
5. 운영 시나리오 7선 — 검색이 실제로 푸는 문제
여기부터가 본론이다. 인덱스 비교는 도구 검증이고, 진짜 가치는 상담팀이 매일 돌리는 쿼리를 풀어내는 것.
5.1 라이브 검색바 — 상담사가 검색창에 입력
상담사가 검색창에 PAYMENT_DECLINED 결제 실패처럼 한/영 혼합 문구를 친다. 매치된 티켓을 최신순으로 20건, 매치 부분 ±30자 발췌(snippet)와 함께.
요점은 hasAnyTokens — 입력을 같은 토크나이저로 분해해 inverted index의 posting list를 합치는 함수다. tokenbf_v1로는 비슷한 동작이 불가능하다 (OR (hasToken AND hasToken) 식의 풀어쓰기가 필요한데, 한국어에선 어차피 recall이 깨진다).
5.2 급증 이슈 탐지 — 최근 24시간에 무엇이 폭증했나
CS 운영의 핵심 지표. 워치리스트 키워드를 최근 24시간 vs 직전 7일 평균과 비교해 3× 이상 spike를 Slack으로 알린다.
여기서 positionUTF8을 쓰는 건 정말로 풀스캔이 아니라 — text 인덱스가 깔린 컬럼에서 positionUTF8(body, term) > 0은 옵티마이저가 인덱스의 dictionary lookup으로 풀어준다. index가 있는데도 LIKE/position이 동작하는 게 핵심 편의성.
5.3 유사 티켓 추천 — agent assist의 기본형
새 티켓이 들어왔을 때 본문의 핵심 토큰을 추출해 해결된 유사 티켓 K개를 찾는다.
hasAnyTokens로 후보를 1차 prune, arrayCount로 공통 토큰 수를 점수화. 벡터 검색(임베딩 기반)보다 단순하지만 해결책이 있는 티켓에 한정하면 놀랍게 잘 동작한다 — 게다가 추가 인프라(임베딩 모델, ANN 인덱스)가 0이다.
5.4 FAQ 자동 매칭 — "did you mean?" 박스의 무-ML 버전
지식베이스 문서의 keywords 배열을 검색의 needle 풀로 사용:
SELECT
s.ticket_id, s.category, a.article_id, a.title,
arrayCount(k -> positionUTF8(s.body, k) > 0, a.keywords) AS keyword_hits
FROM support_search.tickets_text s
LEFT JOIN support_search.articles a ON s.category = a.category
WHERE s.status IN ('open','in_progress')
AND arrayCount(k -> positionUTF8(s.body, k) > 0, a.keywords) > 0
ORDER BY s.ticket_id, keyword_hits DESC
LIMIT 30;문서는 보통 ~10건 수준의 소량이므로 CROSS JOIN으로도 충분히 빠르다.
5.5 토픽별 MTTR — 어떤 문제 유형이 가장 오래 걸리나
category 컬럼이 잡아내지 못하는 세분화된 문제를 본문 키워드로 정의해 평균/p90 해결시간을 잰다.
multiSearchAny는 SIMD 한 번에 여러 needle을 동시 매치하므로 인덱스가 없어도 LIKE OR 체이닝보다 빠르다. 인덱스가 있으면 prune까지 더해진다.
5.6 대화 escalation 탐지 — 문제는 첫 응답 이후에 시작된다
티켓 메시지의 2번째 이후 고객 메시지에서 escalation 신호어를 찾는다. 같은 티켓 안에서도 첫 응답 이후 고객이 다시 호소하는 케이스를 우선순위로 끌어올린다.
이건 dashboard SLA 알람의 직접 입력으로 쓰인다.
5.7 대시보드 MV — 1분마다 100만 스캔하지 않는다
CS 대시보드가 분 단위로 새로고침된다면 매번 1M 풀스캔은 낭비다. 쓰는 시점에 토픽 매핑을 미리 굳혀둔다.
이후 대시보드 쿼리는:
SELECT day, topic, countMerge(tickets) AS n
FROM topic_daily WHERE day >= today() - 30
GROUP BY day, topic ORDER BY day DESC, n DESC;원천 테이블 크기와 무관하게 ms 단위. MV는 풀텍스트 검색의 비용을 쓰는 쪽으로 옮기는 가장 강력한 패턴이다.
6. 운영 — 인덱스 라이프사이클 관리
text 인덱스는 storage가 크기 때문에 추가/제거/재구축이 다른 인덱스보다 무겁다. 운영팀이 알아야 할 것들:
6.1 인덱스 사용 검증 — 진짜 prune하는지
skip index가 한 번도 작동하지 않으면 그건 데드 웨이트다. EXPLAIN indexes = 1로 확인:
EXPLAIN indexes = 1
SELECT count() FROM tickets_text WHERE hasAnyTokens(body, ['결제','환불']);이게 텍스트 인덱스의 Skip Index 라인을 보여주면 OK. 안 나오면 쿼리가 인덱스를 안 타고 있는 것 — 보통 함수 호출이 인덱스가 인식하지 못하는 형태로 들어간 경우다.
6.2 추가/제거는 runtime — backfill 시점이 운영 포인트
MATERIALIZE INDEX는 mutation이라 대용량 테이블에서 IO bound가 된다. 대규모 백필은 운영 시간 외에 또는 새 파티션으로 자연스럽게 채워지도록 두는 게 안전하다.
6.3 인덱스 비용 가시화
매 운영회의용 한 줄 쿼리:
text 인덱스가 본문보다 큰 상황이 정상이라는 걸 모르고 보면 깜짝 놀란다 — 사전에 표로 공유해두자.
7. 권장 default 조합 — 한 줄 결론
요약 카드:
항목 | 권장 |
기본 인덱스 | text(tokenizer = asciiCJK) (또는 환경에 따라 splitByNonAlpha) |
클라이언트 정규화 | 조사/띄어쓰기/어간 변형 OR 확장 (필수) |
짧은 substring 보강 | text(ngrams(2)) 별도 컬럼에 추가 |
한국어 형태소 정밀도 필수 시 | 외부 형태소 분석 → text(tokenizer = array) |
Deprecated 우회 | tokenbf_v1, ngrambf_v1은 신규 도입에선 피할 것 |
대시보드 | multiSearchAny 기반 토픽 매핑을 MV로 사전 집계 |
운영 모니터링 | system.data_skipping_indices + EXPLAIN indexes=1 정기 확인 |
8. 마무리
한국어 풀텍스트 검색은 인덱스 하나로 끝나지 않는다. 두 층이 필요하다.
- DB 층:
text인덱스로 dictionary + posting list를 깐다. 디스크는 본문보다 커질 수 있지만,hasAnyTokens/hasAllTokens/hasPhrase/LIKE등 풍부한 API를 얻는다. - 클라이언트 층: 조사 변형, 띄어쓰기 변형, 복합명사 양형(
주문번호vs주문 번호)을 쿼리 정규화 단계에서 OR로 펼친다.
이 두 층을 동시에 깔지 않으면 어느 한쪽이 새어 recall이 절반이 되는 광경을 보게 된다. 200K 티켓 위에서 hasToken('결제')가 47,368건 중 23,489건만 잡아낸 것이 그 단적인 예다.
운영 시나리오 7가지 — 라이브 검색, 급증 탐지, 유사 티켓, FAQ 매칭, MTTR, escalation 탐지, 대시보드 MV — 는 모두 같은 패턴을 따른다: hasAnyTokens/multiSearchAny로 후보를 인덱스에서 좁힌 뒤, 작은 후보 셋 위에서 arrayCount/positionUTF8로 풍부한 점수화/추출을 한다. 외부 ML 인프라를 도입하지 않고도 agent-assist급 UX를 만들 수 있는 길이 ClickHouse 안에 이미 있다.
26.2의 text 인덱스 GA는 한국어 검색을 오픈소스 ClickHouse만으로 프로덕션에 올릴 수 있게 했다는 데 의의가 크다. 다만 디스크 비용과 빌드별 tokenizer 차이는 도입 전 반드시 측정·검증해야 한다. 이 글이 그 측정의 출발점이 됐기를.
참고 자료
- ClickHouse Full-text Search with Text Indexes
- ClickHouse Release 26.2 — text index GA
- Data skipping indexes 베스트 프랙티스
- system.tokenizers — 사용 중인 클러스터에서 가용 tokenizer 확인
hasAllTokens/hasAnyTokens/hasPhrase/multiSearchAny함수 레퍼런스- 본 글의 모든 실측치는 ClickHouse Cloud 26.2.1.390 · 200K 한/영 혼용 티켓 데이터 기준