1편에서 '머신러닝'으로 찾을 때와 '기계학습'으로 찾을 때 1위가 다르게 나오는 걸 봤다. 키워드 검색이 뜻이 아니라 글자로 찾기 때문이었다. 그럼 그 '글자로 찾는다'는 게 속에서 정확히 어떻게 돌아가는 걸까?
지금 내 서비스는 하이브리드 검색을 쓴다. 하지만 그 절반은 여전히 키워드 검색이다. 그래서 이번 편은 키워드 검색의 속을 열어 본다. 역색인, 한국어 형태소 분석, BM25 점수 계산까지 파이썬으로 직접 만들어 보고, 실무에서 쓰는 Elasticsearch 설정까지 정리했다. (1편: 키워드·벡터·하이브리드 검색 개념 정리)
검색엔진은 책 뒤의 '찾아보기'를 미리 만들어 둔다
SI 프로젝트에서 흔히 만드는 검색은 SQL LIKE '%검색어%'다. 이건 검색할 때마다 모든 행을 처음부터 끝까지 훑는다. 문서가 3만 건이면 3만 건을 매번 다 읽는다.
검색엔진은 반대로 한다. 문서를 넣을 때 미리 "어떤 단어가 몇 번 문서에 있는지" 목록을 만들어 둔다. 이걸 역색인(inverted index)이라고 한다. 책 뒤의 찾아보기와 똑같다. 자바로 치면 Map<String, Set<Integer>> 하나다.
가짜 사내 문서 5개로 실제로 만들어 보면 이렇다. (0번이 'GPU서버 사용 신청' 문서, 1번이 '기계학습 교육' 문서, 4번이 '딥러닝 설치 가이드' 문서)
머신 -> 문서 [0]
러닝 -> 문서 [0, 4]
학습 -> 문서 [0, 1]
기계 -> 문서 [1]
GPU -> 문서 [0]
서버 -> 문서 [0]
문서 -> 문서 [2]
검색어 '서버'가 들어오면 표에서 '서버' 줄만 찾으면 된다. 문서를 훑을 필요가 없으니 몇만 건이어도 빠르다. 대신 문제가 하나 생긴다. 문서를 어떤 단위로 잘라서 표에 넣느냐가 검색 품질을 거의 다 정한다.
한국어는 자르는 것부터 일이다
영어는 띄어쓰기로 잘라도 대충 된다. 한국어는 안 된다. '서버를', '서버가', '서버에서'가 전부 다른 단어가 돼 버린다. 직접 비교해 봤다.
| 문서: "머신러닝 모델 학습용 GPU서버 사용 신청 절차" | 질문: "GPU 서버를 신청하려면" | |
|---|---|---|
| 띄어쓰기로 자르기 | 머신러닝 / 모델 / 학습용 / GPU서버 / 사용 / 신청 / 절차 | GPU / 서버를 / 신청하려면 |
| 형태소 분석(Kiwi) | 머신 / 러닝 / 모델 / 학습 / GPU / 서버 / 사용 / 신청 / 절차 | GPU / 서버 / 신청 |
띄어쓰기로 자르면 겹치는 단어가 하나도 없다. 결과는 0건이다. 형태소 분석기로 조사('를')와 어미('하려면')를 떼어 내니 'GPU', '서버', '신청' 세 단어가 모두 맞아서 바로 찾았다.
한국어 형태소 분석기는 여러 개가 있다. 이 글에서는 파이썬에서 설치가 쉬운 Kiwi를 썼다. Elasticsearch에서는 공식 한국어 플러그인인 Nori를 많이 쓴다(mecab-ko-dic 사전 기반).
그런데 위 표에 함정이 하나 있다. '머신러닝'이 '머신'과 '러닝'으로 쪼개졌다. 그래서 1편에서 '머신러닝'을 검색하면 '딥러닝' 문서가 '러닝' 덕분에 같이 올라왔다. 잘게 자르면 더 많이 찾지만, 엉뚱한 것도 같이 찾는다. 이건 아래에서 사용자 사전으로 고친다.
BM25, 점수는 이렇게 매긴다
찾은 문서들의 순서는 BM25라는 공식으로 정한다. Elasticsearch의 기본 점수 방식이다. 공식은 복잡해 보이지만 재료는 세 개뿐이다.
- 드문 단어일수록 점수가 크다(IDF): 모든 문서에 다 있는 단어('안내', '방법')는 구분에 도움이 안 된다. 몇 개 문서에만 있는 단어('GPU')가 맞으면 점수를 많이 준다.
- 많이 나올수록 크지만, 끝없이 크진 않다(TF 포화): 같은 단어가 10번 나온다고 1번 나온 문서보다 10배 중요하진 않다. 계산해 보면 이렇다(k1=1.2 기본값).
| 단어가 나온 횟수 | 1번 | 2번 | 3번 | 5번 | 10번 | 20번 |
|---|---|---|---|---|---|---|
| 점수 기여(tf 부분) | 1.00 | 1.38 | 1.57 | 1.77 | 1.96 | 2.08 |
- 긴 문서는 조금 깎는다(문서 길이 보정): 긴 문서는 아무 단어나 들어 있을 확률이 높다. 평균보다 긴 문서는 점수를 살짝 낮춘다. 얼마나 깎을지가 b(기본 0.75)다.
그래서 키워드 검색이 잘 찾는 문서는 "드문 검색어가, 적당히 여러 번, 짧은 문서 안에" 들어 있는 문서다.
직접 만들어 보기: 파이썬 45줄
위 세 가지를 그대로 코드로 옮겼다. 점수 식은 Lucene(Elasticsearch)과 같은 식을 썼다. 필요한 건 pip install kiwipiepy 하나다.
import math
from collections import defaultdict, Counter
from kiwipiepy import Kiwi
docs = [
"머신러닝 모델 학습용 GPU서버 사용 신청 절차",
"기계학습 기초 교육 과정 안내",
"인공지능 기반 문서 분류 시스템 운영 매뉴얼",
"출장비 정산 규정 및 영수증 제출 방법",
"딥러닝 프레임워크 설치 가이드",
]
# 1) 형태소 분석: 명사, 영어, 숫자, 어근, 동사만 남긴다
kiwi = Kiwi()
KEEP = ("NNG", "NNP", "SL", "SN", "XR", "VV")
def tokenize(text):
return [t.form for t in kiwi.tokenize(text) if t.tag in KEEP]
# 2) 역색인: 단어 -> 그 단어가 든 문서 번호들
index = defaultdict(set)
tfs, lens = [], []
for i, d in enumerate(docs):
toks = tokenize(d)
tf = Counter(toks) # 문서 안에서 단어별 등장 횟수
tfs.append(tf); lens.append(len(toks))
for t in tf:
index[t].add(i)
# 3) BM25 점수 (Lucene/Elasticsearch와 같은 식)
def bm25(query, k1=1.2, b=0.75):
N, avg = len(docs), sum(lens) / len(docs)
scores = [0.0] * N
for q in tokenize(query):
n = len(index.get(q, [])) # 이 단어가 든 문서 수
if n == 0:
continue
idf = math.log(1 + (N - n + 0.5) / (n + 0.5))
for i in index[q]:
tf = tfs[i][q]
norm = k1 * (1 - b + b * lens[i] / avg)
scores[i] += idf * tf * (k1 + 1) / (tf + norm)
return sorted(((round(s, 2), docs[i]) for i, s in enumerate(scores) if s > 0), reverse=True)
print(bm25("GPU 서버를 신청하려면"))
print(bm25("머신러닝"))
돌려 보면 이렇게 나온다.
[(3.72, '머신러닝 모델 학습용 GPU서버 사용 신청 절차')]
[(2.03, '머신러닝 모델 학습용 GPU서버 사용 신청 절차'), (0.93, '딥러닝 프레임워크 설치 가이드')]
"GPU 서버를 신청하려면"은 제대로 찾았다. 그런데 '머신러닝'에는 여전히 '딥러닝' 문서가 따라오고, 기계학습 교육 문서는 나오지 않는다. 참고로 1편의 점수(1.27 등)와 숫자가 다른 건 1편에서 쓴 라이브러리(rank_bm25)와 IDF 계산식이 조금 달라서다. 순서는 같다.
'머신러닝 = 기계학습'을 가르치는 법: 사용자 사전과 동의어 사전
키워드 검색은 뜻을 모른다. 그래서 사람이 알려 줘야 한다. 방법은 두 가지다.
- 사용자 사전: '머신러닝'처럼 쪼개면 안 되는 말을 한 단어로 등록한다.
- 동의어 사전: 질문에 '머신러닝'이 있으면 '기계학습'도 같이 찾게 한다.
# 사용자 사전: 붙여 써야 하는 말은 한 단어로
kiwi.add_user_word("머신러닝", "NNP")
kiwi.add_user_word("기계학습", "NNP")
kiwi.add_user_word("딥러닝", "NNP")
# 동의어 사전: 질문에 같은 뜻 단어를 덧붙인다
SYN = {"머신러닝": ["기계학습"], "기계학습": ["머신러닝"],
"AI": ["인공지능"], "인공지능": ["AI"]}
def expand(tokens):
return tokens + [s for t in tokens for s in SYN.get(t, [])]
이렇게 바꾸고 다시 돌렸다.
| 질문 | 바꾸기 전 | 바꾼 뒤 |
|---|---|---|
| '머신러닝' | 1위 GPU서버 문서 2위 딥러닝 문서 (엉뚱) 기계학습 교육 문서 없음 | 1위 기계학습 교육 문서 (1.51) 2위 GPU서버 문서 (1.24) 딥러닝 문서 빠짐 |
| '기계학습' | 1위 기계학습 교육 문서 2위 GPU서버 문서 ('학습' 글자만 겹침) | 위와 똑같은 결과 |
| 'AI 문서 분류' | 1위 인공지능 문서 분류 매뉴얼 | 1위 같음 ('인공지능'도 같이 찾음) |
이제 '머신러닝'으로 찾든 '기계학습'으로 찾든 같은 결과가 나온다. 1편에서 겪은 문제가 사전 몇 줄로 풀렸다. 대신 대가가 있다. 이 사전은 사람이 계속 관리해야 한다. 새 용어, 사내 약어, 시스템 이름이 생길 때마다 넣어 줘야 한다. 문서가 3만 건이면 사전도 같이 커진다.
실무에서는 Elasticsearch로: Nori 설정 예시
직접 만든 코드는 원리 공부용이다. 실제 서비스에서는 Elasticsearch(또는 OpenSearch)를 쓴다. 위에서 한 일을 Elasticsearch 설정으로 옮기면 이렇다. Elastic 공식 문서의 Nori 플러그인 설명을 기준으로 정리한 예시다.
PUT tech_docs
{
"settings": {
"analysis": {
"tokenizer": {
"ko_tok": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary_rules": ["머신러닝", "기계학습", "GPU서버 GPU 서버"]
}
},
"filter": {
"ko_pos": { "type": "nori_part_of_speech" },
"ko_syn": {
"type": "synonym_graph",
"synonyms": ["머신러닝, 기계학습", "AI, 인공지능"]
}
},
"analyzer": {
"ko_index": { "type": "custom", "tokenizer": "ko_tok", "filter": ["ko_pos", "lowercase"] },
"ko_search": { "type": "custom", "tokenizer": "ko_tok", "filter": ["ko_pos", "lowercase", "ko_syn"] }
}
}
},
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ko_index", "search_analyzer": "ko_search" },
"body": { "type": "text", "analyzer": "ko_index", "search_analyzer": "ko_search" },
"doc_no": { "type": "keyword" }
}
}
}
- nori_tokenizer: 한국어 형태소 분석.
decompound_mode는 복합어 처리 방식이다.discard(기본값)는 쪼갠 조각만 남기고,mixed는 원래 단어와 조각을 둘 다 남긴다.none은 쪼개지 않는다. - user_dictionary_rules: 사용자 사전. "GPU서버 GPU 서버"는 'GPU서버'를 한 단어로 두되 'GPU'와 '서버'로도 찾게 하라는 뜻이다.
- nori_part_of_speech: 조사·어미 같은 품사를 걸러 낸다. 위 파이썬 코드의
KEEP과 같은 역할이다. - synonym_graph: 동의어 사전. 문서를 넣을 때가 아니라 검색할 때만(search_analyzer) 적용했다. 이렇게 하면 동의어를 바꿔도 문서 3만 건을 다시 넣지 않아도 된다.
- doc_no는 keyword: 문서번호처럼 글자가 정확히 맞아야 하는 값은 쪼개지 않는 keyword 타입으로 둔다. 키워드 검색이 벡터 검색보다 확실히 잘하는 부분이 바로 여기다.
검색은 이렇게 한다. title^2는 제목에서 맞으면 본문보다 2배 쳐 주라는 뜻이다.
GET tech_docs/_search
{
"query": {
"multi_match": {
"query": "머신러닝 교육 자료",
"fields": ["title^2", "body"]
}
}
}
나도 지금 검색 서버에서 이런 사전을 쓰고 있다.
정리하면 키워드 검색은 사전을 키우는 만큼 똑똑해진다. 그래도 사전에 없는 말이나 문장으로 묻는 질문의 '의도'까지는 못 잡는다. 그래서 다음 편은 뜻으로 찾는 벡터 검색이다.
이상 키워드 검색 제대로 알기 끝!


댓글
댓글 쓰기