Full-Text Search 기반 한/영 고객지원 데이터 검색
🏭

Full-Text Search 기반 한/영 고객지원 데이터 검색

ClickHouse 분류
Case Study
Type
Lab
작성자

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에서 일어난 변화 두 가지:

  1. text 인덱스가 GA됐다. allow_experimental_full_text_index 설정은 obsolete가 됐고 별도 활성화 없이 바로 쓸 수 있다.
  2. 공식 권장사항이 바뀌었다 — tokenbf_v1, ngrambf_v1deprecated다 (작동은 함). 새 워크로드에는 text(...)를 쓰는 게 권장된다.

2.1 tokenizer 선택 — asciiCJK의 함정

text 인덱스의 핵심 파라미터는 tokenizer다. 공식 문서가 한/영 혼용에 권장하는 것은 asciiCJK — Unicode word boundary를 따르고 CJK 문자를 단일 토큰화한다.

그러나 실측 환경(ClickHouse Cloud 26.2.1.390)에서는 asciiCJKsystem.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.

splitByNonAlphangrams(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('결제')은 "결제 "라는 공백 분리된 단독 토큰만 잡고 나머지 절반 이상을 놓친다.

해결책 두 가지가 공존해야 한다.

  1. 인덱스 측 — LIKE로 풀어 substring 매치. text 인덱스(splitByNonAlpha)에서 LIKE '%결제%'는 토큰 매치가 아니라 inverted index의 dictionary 탐색을 거쳐 정상 recall이 나온다. ngrambf_v1에서도 OK.
  2. 클라이언트 측 — 조사 변형 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 차이는 도입 전 반드시 측정·검증해야 한다. 이 글이 그 측정의 출발점이 됐기를.

참고 자료

소스코드