한글 2글자 검색이 0건이 되는 이유 - min_length 필터와 nori
운영 중인 데이터 카탈로그(DataHub)에서 “결제”, “방문” 같은 2글자 한글 키워드로 검색하면 결과가 0건이고, 3글자부터는 정상 동작하는 현상을 추적한 기록입니다. 원인은 검색엔진(OpenSearch)의 결함이 아니라, 애플리케이션이 인덱스에 심어놓은 token filter 하나였습니다. 역색인과 analyzer의 기본 동작부터 시작해서, 원인을 실측으로 확정하고, 세 번의 시도 끝에 해결하기까지의 과정을 정리합니다.
문제 상황
증상은 단순하고 명확했습니다.
검색어 "결제" → 0건
검색어 "방문" → 0건
검색어 "유저", "정산" → 0건
검색어 "결제자" (3글자) → 수백 건 정상 매칭
특징을 정리하면 이렇습니다.
- 2글자 한글 키워드는 데이터셋명·컬럼명·설명(description) 어디를 겨냥해도 전부 0건입니다.
- 3글자부터는 정상입니다. “결제”는 0건인데 “결제자”는 수백 건이 나옵니다.
- 심지어 description에 독립된 단어로 “결제”가 그대로 적혀 있는 문서조차 “결제”로 검색되지 않습니다. 데이터가 없어서가 아니라, 데이터가 있는데도 못 찾는 상황입니다.
“3글자부터 정상”이라는 경계가 지나치게 깔끔해서, 어딘가에 3이라는 숫자가 하드코딩되어 있으리라는 심증부터 생깁니다. 이 숫자를 찾으러 가기 전에, 검색엔진이 텍스트를 어떻게 다루는지부터 짚고 시작하겠습니다. 원인이 정확히 이 기초 위에 있기 때문입니다.
기초: 역색인과 analyzer
역색인 — 토큰이 없으면 존재하지 않는 것과 같다
Elasticsearch/OpenSearch는 검색할 때마다 문서 원문을 훑는 방식(grep)이 아닙니다. 색인 시점에 문서의 텍스트를 토큰(token) 단위로 쪼갠 뒤, “이 토큰이 어느 문서에 있는가”를 담은 역방향 사전, 즉 역색인(inverted index) 을 만들어 둡니다.
flowchart LR
classDef doc fill:#dbeafe,stroke:#1d4ed8
classDef idx fill:#dcfce7,stroke:#15803d
D1["doc1<br/>'주문 결제 내역 테이블'"]:::doc
D2["doc2<br/>'B2C 결제자 table'"]:::doc
subgraph inv["역색인 (토큰 → 문서 목록)"]
T1["'결제자' → doc2"]:::idx
T2["'b2c' → doc2"]:::idx
T3["'tabl' → doc2"]:::idx
end
D1 --> inv
D2 --> inv
검색은 이 사전에서 토큰을 key로 lookup하는 것입니다. 그래서 대원칙이 하나 생깁니다.
사전에 토큰이 등재되어 있지 않으면, 그 단어는 존재하지 않는 것과 같다. 원문에 “결제”라는 글자가 아무리 많아도, 토큰 “결제”가 사전에 없으면 0건이다.
analyzer — 토큰을 만드는 3단 파이프라인
텍스트를 토큰으로 바꾸는 장치가 analyzer이고, 세 단계로 구성됩니다.
flowchart TB
classDef stage fill:#fef3c7,stroke:#a16207
classDef danger fill:#fee2e2,stroke:#b91c1c
IN["원문 텍스트"]
CF["char filter<br/>문자 단위 전처리 (HTML 제거 등)"]:::stage
TK["tokenizer<br/>텍스트를 토큰으로 분할<br/>whitespace / pattern / nori_tokenizer ..."]:::stage
TF["token filter (여러 개 체인)<br/>토큰을 변형·추가·삭제<br/>lowercase, stop, length ..."]:::danger
OUT["최종 토큰들 → 역색인 등재"]
IN --> CF --> TK --> TF --> OUT
핵심은 token filter가 토큰을 삭제할 수도 있다는 점입니다. length filter에 min: 3을 주면 3글자 미만 토큰은 이 단계에서 조용히 사라집니다.
analyzer는 “두 번” 적용된다
analyzer는 색인할 때 한 번만 도는 것이 아닙니다. 검색어에도 적용됩니다.
flowchart LR
classDef write fill:#dbeafe,stroke:#1d4ed8
classDef read fill:#fce7f3,stroke:#be185d
subgraph W["색인 시점 (write path)"]
WD["문서 'B2C 결제자 table'"]:::write
WA["index analyzer"]:::write
WT["[b2c, 결제자, tabl]"]:::write
WD --> WA --> WT
end
subgraph R["검색 시점 (read path)"]
RD["검색어 '결제자'"]:::read
RA["search analyzer"]:::read
RT["[결제자]"]:::read
RD --> RA --> RT
end
WT <-->|"토큰 == 토큰 매칭"| RT
양쪽에서 만들어진 토큰끼리 일치해야 검색이 성립합니다. 따라서 토큰을 지우는 필터가 양쪽 체인에 모두 들어 있으면 피해가 두 배가 됩니다 — 색인에도 없고, 질의 토큰도 없습니다.
multi-field — 같은 원문을 여러 방식으로 색인
하나의 필드를 서로 다른 analyzer로 여러 벌 색인해 두고 검색 목적에 따라 골라 쓰는 것이 관례이고, DataHub도 그렇게 합니다.
description (원문 1개)
├── description keyword (원문 통짜, 정확 일치·정렬용)
├── description.delimited ← word_delimited analyzer (일반 검색용) ★ 문제의 필드
├── description.ngram ← search_as_you_type (자동완성용)
└── description.wordGrams2/3/4 ← 2~4단어 shingle (구 단위 가중치용)
같은 원문이라도 subfield마다 등재된 토큰이 다릅니다. 이 사실이 뒤에서 중요한 단서가 됩니다.
원인 추적
첫 번째 추정은 틀렸다: min_gram이 아니다
처음에는 “n-gram의 min_gram=3 때문에 2글자가 잘리는 것”으로 추정했습니다. 그럴듯한 가설이지만, 소스를 열어보니 사실이 아니었습니다. DataHub의 .ngram subfield는 고전적인 n-gram tokenizer가 아니라 search_as_you_type 타입이라서, min_gram이라는 설정 자체가 존재하지 않습니다. 게다가 이 필드는 자동완성 경로에서만 질의되고 일반 검색에서는 조회 대상도 아닙니다.
가설이 기각됐으니 다시 코드로 돌아갑니다. 인덱스의 analyzer 정의는 helm values 같은 배포 설정이 아니라 Java 코드에 하드코딩되어 있습니다 (metadata-io의 SettingsBuilder.java). 거기서 문제의 3을 찾았습니다.
filters.put(MIN_LENGTH,
ImmutableMap.<String, Object>builder()
.put(TYPE, "length")
.put("min", "3")
.build());
min_length라는 이름의 length token filter입니다. 그리고 이 필터는 색인용·검색용 analyzer 체인 양쪽의 마지막에 들어 있습니다.
word_delimited (색인용) / query_word_delimited (검색용)
────────────────────────────────────────────────────
tokenizer: pattern "[(),./:]" ← 공백으로는 안 쪼갬
token filters: asciifolding
word_delimiter_graph ← 여기서 공백·특수문자 기준 서브워드 분리
trim
lowercase
stop
snowball
unique
min_length {type: length, min: 3} ★ 3글자 미만 토큰 삭제
앞 단계에서 어렵게 분리해 낸 서브워드라도, 2글자면 체인의 마지막에서 버려집니다.
_analyze로 실측 재현
가설이 아니라 사실로 만들기 위해 실제 인덱스에 _analyze API를 실행했습니다.
| analyzer | 입력 | 결과 |
|---|---|---|
query_word_delimited (검색용) |
결제 | 토큰 0개 — min_length가 삭제 |
query_word_delimited |
결제자 | [결제자] 생존 |
word_delimited (색인용) |
주문 결제 내역 | 통짜 원본 1개만 생존 — 서브워드 주문/결제/내역이 전부 2글자라 전량 삭제 |
word_delimited |
B2C 결제자 table | [b2c, 결제자, tabl] 정상 분리·생존 |
partial (.ngram용, min_length 없음) |
결제 | 토큰 생존 |
이로써 2글자 한글이 겪는 일이 확정됩니다. 색인 시점과 검색 시점의 이중 차단입니다.
flowchart TB
classDef write fill:#dbeafe,stroke:#1d4ed8
classDef read fill:#fce7f3,stroke:#be185d
classDef cut fill:#fee2e2,stroke:#b91c1c
classDef res fill:#fef3c7,stroke:#a16207
subgraph W["색인 시점"]
D["문서 '...주문 결제 내역...'"]:::write
IA["word_delimited"]:::write
IC["min_length(3) ✂<br/>2글자 서브워드 전멸"]:::cut
II["역색인(.delimited)에<br/>'결제' 토큰 없음"]:::write
D --> IA --> IC --> II
end
subgraph R["검색 시점"]
Q["검색어 '결제'"]:::read
QA["query_word_delimited"]:::read
QC["min_length(3) ✂"]:::cut
QT["질의 토큰 0개"]:::read
Q --> QA --> QC --> QT
end
II --> Z["매칭할 토큰이 양쪽 모두 없음 → 0건"]:::res
QT --> Z
질의 토큰이 0개면 simple_query_string도 match_phrase_prefix도 비교할 대상 자체가 없어 무조건 0건입니다. “3글자부터 정상”인 이유가 정확히 min: 3 경계였고, 3글자 검색어(“결제자”)는 필터를 통과한 토큰이 색인된 긴 토큰의 접두 매칭에 성공하기 때문에 동작했던 것입니다.
한 가지 더 짚을 점은, 이것이 2글자 “검색어” 문제 이전에 색인 품질 자체의 문제라는 사실입니다. 한국어 문서의 2음절 핵심 명사(결제·유저·정산·방문·주문·내역…)가 통째로 역색인에서 빠져 있었습니다.
왜 영어 검색은 멀쩡했나
min: 3은 검색 품질을 해치려고 넣은 값이 아닙니다. 영어를 전제로 한 노이즈 제거 설계입니다. 영어에서 1~2글자 토큰은 대부분 관사·전치사(a, of, in, at)라서 지워도 검색 품질이 오히려 좋아집니다.
그러나 한국어는 2음절이 핵심 명사의 기본 길이입니다. 같은 규칙이 영어에서는 청소부 역할을 하고 한국어에서는 의미어를 대량으로 지웁니다. 실제로 upstream에는 중국어·일본어 사용자들의 동일한 리포트가 수년에 걸쳐 올라와 있었습니다(뒤에서 다시 등장합니다). 영어권 프로젝트의 무해한 기본값이 CJK 환경에서 병리가 되는 전형적인 사례입니다.
해결 여정
원인이 한 지점(min_length의 min=3)으로 확정됐으니 고치면 됩니다. 그런데 “어떻게 고치는가”에서 세 번의 시도가 필요했습니다.
시도 1 — 인덱스 settings override 주입: 조용히 무시됨
DataHub에는 배포 설정으로 인덱스 settings를 override하는 메커니즘이 있습니다. 코드 수정 없이 analysis.filter.min_length.min: "2"를 주입할 수 있다면 가장 싸게 끝나는 경로라서 먼저 검증했습니다.
결과는 무반응이었습니다. 환경변수 주입까지는 정상인데, 인덱스를 관리하는 job이 “변경 없음”으로 판정하고 아무것도 하지 않았습니다. 코드를 따라가 보니 이유가 있었습니다.
- 인덱스 갱신 job의 diff 감지 로직은 코드가 만든 nested
analysis트리(min=3)와 현재 인덱스만 비교합니다. flat dotted key로 들어온 override는 비교 대상 자체가 아닙니다. - 즉 override는 신규 인덱스를 만들 때만 전달될 뿐, 이미 존재하는 인덱스에는 조용히 무시됩니다. 에러도 없고 로그로 구분하기도 어렵습니다.
“설정을 넣었는데 적용이 안 되면, 적용 여부를 판정하는 코드부터 읽어야 한다”는 교훈을 남기고 기각.
시도 2 — nori tokenizer 단독 적용: 한글 검색이 오히려 전멸
한국어 검색 품질의 근본 개선을 위해 형태소 분석기 nori를 적용했습니다. DataHub는 ELASTICSEARCH_MAIN_TOKENIZER 환경변수로 tokenizer를 교체할 수 있어서 fork 수정 없이 적용 가능했고, 재색인도 자동으로 정상 수행됐습니다.
그런데 검증에서 예상 밖의 결과가 나옵니다. 잘 되던 3글자 검색까지 0건으로 회귀했습니다.
flowchart LR
classDef tok fill:#dbeafe,stroke:#1d4ed8
classDef cut fill:#fee2e2,stroke:#b91c1c
classDef res fill:#fef3c7,stroke:#a16207
Q["검색어 '결제자'"] --> N["nori_tokenizer<br/>형태소 분해"]:::tok
N --> T["[결제(2자), 자(1자)]"]:::tok
T --> M["min_length(3) ✂<br/>전부 3글자 미만"]:::cut
M --> Z["토큰 0개 → 0건<br/>(기존엔 '결제자' 통토큰으로 매칭됐음)"]:::res
원리를 보면 당연한 결과입니다. 기존 tokenizer는 “결제자”를 3글자 통토큰으로 남겨서 min_length를 통과했습니다. 그런데 nori는 형태소 단위로 완전 분해합니다 — “결제자”는 [결제, 자]가 되고, 둘 다 3글자 미만이라 min_length가 전량 삭제합니다. 색인 쪽도 마찬가지여서, 한글 문장이 형태소로 잘게 쪼개진 뒤 대부분 지워져 한글 색인이 사실상 비어버립니다.
tokenizer는 filter 체인과 세트로 봐야 합니다. tokenizer를 바꾸면 뒤따르는 filter들의 입력 분포가 통째로 바뀝니다. nori와
min_length(3)의 조합은 “더 좋은 토큰화 + 기존 노이즈 필터”가 아니라 “한글 토큰 전멸기”였습니다. 이런 중간 상태를 운영에 방치하면 개선이 아니라 장애입니다.
결국 nori를 살리려면 min_length 완화가 선택이 아니라 전제 조건임이 확정됐습니다.
시도 3 — upstream을 먼저 확인하니, 이미 해결돼 있었다
min_length를 2로 낮추는 것 자체는 fork에서 1줄 수정이면 됩니다. 하지만 fork를 임의 수정하기 전에 upstream을 조사했고, 여기서 반전이 나옵니다. 동일한 문제를 해결한 PR이 이미 merge되어 있었습니다.
- datahub-project/datahub#13499 — “feat(config): add configurable search filter min length”
ELASTICSEARCH_MIN_SEARCH_FILTER_LENGTH환경변수로 min_length를 설정할 수 있게 하고, 기본값은 3으로 유지 (v1.2.0+ 포함)
이 PR에 도달하기까지의 upstream 이력도 흥미롭습니다. 우리가 겪은 것과 같은 문제를 다른 언어권 사용자들이 이미 여러 번 리포트했습니다.
| 시점 | upstream 활동 | 결과 |
|---|---|---|
| 이슈 #7573 | 중국어 2글자 검색 불가 리포트 | stale close |
| 이슈 #11721 | 한·중·일 2글자 검색 불가 리포트 | — |
| PR #11722 | 하드코딩 3→2 변경 제안 | 메인테이너가 “설정화 + 기본값 유지” 방침 요구, 미머지 |
| PR #12969 | 설정화 1차 시도 | 미머지 |
| PR #13499 | 환경변수 설정화 + 기본값 3 유지 | merge ✓ |
운영 중인 버전은 이 PR 이전(v1.1.0 기반)이라 해당 기능이 없었고, PR #13499를 무수정 backport하는 방식을 택했습니다. git apply --check로 확인하니 충돌 0으로 클린하게 적용됐습니다. 임의로 fork 1줄을 고치는 대신 upstream 원형을 그대로 가져온 이유는 명확합니다.
- 이후 버전 업그레이드 시 충돌 없이 자연 수렴합니다 (upstream에 이미 있는 코드이므로).
- 메인테이너가 리뷰·승인한 설계(“기본값은 유지하고 설정으로 열기”)를 그대로 따릅니다.
- 기본값이 3이라 backport 자체는 무해하고, 환경변수로 2를 주입할 때만 효과가 발생합니다.
최종 구성은 nori + min_length=2 병행입니다. min=2를 유지하는 것에도 의미가 있는데, nori가 만들어내는 1글자 형태소(조사 “자”, “를” 등)는 계속 걸러져서 노이즈 방지 역할을 합니다.
flowchart LR
classDef tok fill:#dbeafe,stroke:#1d4ed8
classDef ok fill:#dcfce7,stroke:#15803d
classDef cut fill:#fee2e2,stroke:#b91c1c
Q["'방문자수집계'"] --> N["nori_tokenizer"]:::tok
N --> T["[방문(2), 자(1), 수집(2), 계(1)]"]:::tok
T --> M["min_length(2)<br/>1글자만 제거"]:::cut
M --> R["[방문, 수집] 생존<br/>→ '방문'으로 복합어 중간까지 검색됨"]:::ok
검증
이미지 배포 + 환경변수 반영 후 자동 재색인 1회를 거쳐, 원인 규명에 썼던 것과 같은 방법으로 before/after를 실측했습니다.
analyzer 레벨 (_analyze):
"결제" → before: [] after: [결제]
"방문자수집계" → before: [방문자수집계] after: [방문, 수집]
"주문 결제 내역" → before: 통짜 1토큰 after: 서브워드 전부 생존
검색 레벨 (검색 API):
| 검색어 | before | after |
|---|---|---|
| 결제 (2글자) | 0건 | 수백 건 |
| 방문 (2글자) | 0건 | 수백 건 |
| 유저 (2글자) | 0건 | 수백 건 |
| 정산 (2글자) | 0건 | 수백 건 |
| 결제자 (3글자, 회귀 대조군) | 수백 건 → nori 단독 중간 상태에서 0건 | 복구 + 확대 |
“결제자” 검색이 “결제”와 같은 건수로 수렴한 것은 nori 형태소 분해([결제, 자] → [결제])의 의도된 동작입니다. 정확 일치만 되던 검색이 복합어 중간 매칭까지 커버하게 되어, 예를 들어 “일별방문자집계” 같은 이름의 데이터셋이 “방문”으로 검색됩니다.
정리
- 역색인에 토큰이 없으면 그 단어는 존재하지 않는 것과 같습니다. 원문에 데이터가 있는데 검색이 안 된다면, 가장 먼저
_analyze로 토큰이 실제로 만들어지는지 확인해야 합니다. - analyzer는 색인·검색 양쪽에 적용되므로, 토큰을 지우는 filter의 피해는 두 배입니다. 색인에도 없고 질의 토큰도 없어서, 어떤 쿼리 튜닝으로도 우회할 수 없습니다.
- tokenizer 교체는 filter 체인과 반드시 함께 검토해야 합니다. nori 단독 적용은 개선이 아니라 한글 검색 전멸이라는 회귀를 만들었습니다. 중간 상태를 운영에 방치하지 않는 것도 중요합니다.
- 영어권 프로젝트의 기본값은 CJK에서 병리가 될 수 있습니다.
min: 3은 영어에서는 노이즈 필터, 한국어에서는 핵심 명사 삭제기입니다. - fork를 수정하기 전에 upstream을 먼저 조사해야 합니다. 같은 문제를 겪은 사람은 대부분 이미 있고, 이번엔 merge된 해결 PR까지 있었습니다. upstream 원형의 backport는 임의 수정보다 업그레이드 수렴·설계 정합성 면에서 훨씬 저렴합니다.
증상은 “2글자 검색이 안 돼요” 한 줄이었지만, 그 아래에는 역색인의 대원칙, analyzer의 이중 적용, tokenizer와 filter의 결합, 그리고 upstream 생태계의 이력이 겹겹이 놓여 있었습니다. 검색이 “안 되는” 문제는 대부분 쿼리가 아니라 토큰이 만들어지는 지점에서 시작한다는 것이 이번 디버깅의 가장 큰 교훈입니다.