기본 콘텐츠로 건너뛰기

키워드 검색 제대로 알기: 역색인·BM25·한국어 형태소 분석을 파이썬으로 직접 만들어 봤다 [RAG 검색 이해하기 2편]

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의 기본 점수 방식이다. 공식은 복잡해 보이지만 재료는 세 개뿐이다.

BM25 점수의 재료 세 가지: IDF, TF 포화, 문서 길이 보정
  • 드문 단어일수록 점수가 크다(IDF): 모든 문서에 다 있는 단어('안내', '방법')는 구분에 도움이 안 된다. 몇 개 문서에만 있는 단어('GPU')가 맞으면 점수를 많이 준다.
  • 많이 나올수록 크지만, 끝없이 크진 않다(TF 포화): 같은 단어가 10번 나온다고 1번 나온 문서보다 10배 중요하진 않다. 계산해 보면 이렇다(k1=1.2 기본값).
단어가 나온 횟수1번2번3번5번10번20번
점수 기여(tf 부분)1.001.381.571.771.962.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"]
    }
  }
}

나도 지금 검색 서버에서 이런 사전을 쓰고 있다.

정리하면 키워드 검색은 사전을 키우는 만큼 똑똑해진다. 그래도 사전에 없는 말이나 문장으로 묻는 질문의 '의도'까지는 못 잡는다. 그래서 다음 편은 뜻으로 찾는 벡터 검색이다.

이상 키워드 검색 제대로 알기 끝!

댓글

이 블로그의 인기 게시물

싱가포르 여행 필수 준비물: G타입 변환 어댑터 (돼지코 전기 플러그)

조만간 우리 아이들이 싱가포르로 여행을 간다. 핸드폰 충전은 물론이고 다른 전기 제품 사용 때문에 어댑터가 꼭 필요하다. 해외여행 갈 때 어댑터 하나 잘못 챙겨가면 충전도 못 하고 전기 사용도 못 해서 낭패 보는 경우가 많다. 10년 전에 내가 싱가포르에 출장 갈 일이 있었는데 그때는 멀티 어댑터를 누군가에게 빌려갔었다. 이번 기회에 아이들 여행 준비물 중에 하나인 변환 플러그 검색하면서 알게 된 싱가포르 사용하는 전압과 플러그 타입, 어떤 어댑터를 챙겨야 하는지를 공유하려 한다. 싱가포르 여행을 계획하는 사람들을게 도움이 되겠지?  내 자식들 챙겨주는 마음으로 꼼꼼하게 설명해 줄 테니, 싱가포르 여행 준비물 리스트에 어댑터 관련 정보가 필요한 사람들은 꼭 참고했으면 한다. 1. 싱가포르 전기 환경 (전압과 플러그 타입) 해외여행 시 현지 전기 환경을 파악하는 것은 필수적인 여행 준비물 점검 사항이다. 싱가포르는 한국과 다른 전압 및 플러그 타입을 사용한다. 1.1 싱가포르 전압 (Voltage) 싱가포르는 230V(볼트) 전압을 사용한다. 한국은 220V를 사용하므로 전압 자체는 큰 차이가 없다. 대부분의 최신 전자기기(휴대폰 충전기, 노트북 충전기 등)는 100~240V의 프리볼트(Free Voltage)를 지원하므로, 전압 변환기(변압기)는 별도로 필요하지 않다. 1.2 싱가포르 플러그 타입 (Plug Type) 및 사용 국가 싱가포르는 Type G 플러그를 사용한다. 이는 영국식 3핀(세 발) 플러그 형태로, 한국(Type C, F)과는 모양이 완전히 다르다. 따라서 한국에서 사용하는 전자기기를 싱가포르에서 사용하려면 반드시 Type G 형태의 어댑터가 필요하다. Type G 플러그를 사용하는 주요 국가   아시아: 싱가포르, 홍콩, 말레이시아, 아랍에미리트(UAE) 등 유럽: 영국, 아일랜드, 몰타, 키프로스 등 기타: 사우디아라비아, 카리브해 일부 국가 등 이러한 국가들을 여행할 때도 Type G 어댑터 하나만 있으면 전기 ...

제주도 탐나는전 결제 사용 후기 (실물카드 미소지로 사용 실패)

제주도 여행을 앞두고 지역화폐인 '탐나는전'을 신청했다. 평소 성남사랑상품권을 잘 쓰고 있어서 제주도에서도 비슷하게 편리하게 쓸 수 있을 거라 생각했거든. 그런데... 결론부터 말하면 한 번도 써보지 못하고 환불하게 됐다. 탐나는전 신청과 설정 제주 출발 전에 미리 탐나는전을 신청했어. 신청 후 1주일 이내에 집에 도착.. 안내 브로셔를 보니 꽤 체계적으로 되어있더라고. 캐시백 혜택부터 시작해서 카드 등록, 사용법까지 자세히 나와있었어. 특히 소득공제 혜택도 있고, 한도상향 완료라는 문구도 보이니 더욱 기대가 됐어. 성남사랑상품권처럼 잘 활용할 수 있을 거라 생각했거든. 카드 등록 카드 등록을 진행했어. 인터페이스도 깔끔하고 사용하기 편해 보였어. "탐나는전 사용 가능한 카드를 신청 또는 등록해주세요!"라는 안내 문구를 보며 설레었지. 카드 정보를 직접 입력하거나 바코드로 인식할 수 있게 되어있어서 편리했어. 성남사랑상품권 앱과 비슷한 느낌이었거든. 난 바코드로 한큐에 등록.. 출발 전날인 6월 24일에 10만원을 충전했어. 탐나는전 선불카드로 등록도 완료하고, 제주도에서 맛있는 것도 사고 기념품도 살 생각에 들떠있었지. 그런데... 현실은? 제주도에 도착해서 탐나는전을 써보려고 했는데, 큰 문제가 있었어. 성남사랑상품권처럼 QR코드나 바코드로 간편하게 결제할 수 있을 거라 생각하고 실물카드를 안 가져갔거든. 그런데 제주도 매장들은 대부분 실물카드만 받더라고! QR코드나 바코드 결제를 지원하는 곳이 거의 없어서 당황했어. 성남에서는 거의 모든 곳에서 앱으로 간편결제가 되니까 당연히 제주도에서도 될 거라 생각했는데... 완전 오산이었어 😢 결국 환불... 여행 내내 한 번도 써보지 못하고, 마지막 날인 6월 29일에 결국 10만원 전액 환불했어. 충전 수수료나 환불 수수료 같은 건 없어서 다행이었지만, 뭔가 허무한 기분이었어. 후기와 조언 탐나는전 자체는 나쁘지 않은 시스템인 것 같아. 소득공제 혜택도 있고, 앱도 사용하기 편해...

미국 주식 투자 환전 수수료 비교 (증권사 환율우대 100% 찾는 법)

나는 달러 환테크와 미국 주식 투자를 병행하고 있다. 단순히 달러를 사고파는 것을 넘어, 환전한 달러로 바로 미국 주식을 매매할 수 있어야 하므로 증권사 환전 서비스를 이용하는 것은 필수다. 혹시 미국 주식을 아직도 한번도 매매 못한 사람을 위해 일반적인 프로세스는 아래와 같다. 이때 가장 중요한 것이 바로 '환전수수료' , 즉 환율우대 혜택이다. 1%, 0.1%의 수수료 차이가 투자 수익률에 큰 영향을 미치기 때문이다. 오늘은 미국 주식 투자를 위한 증권사별 환전 서비스를  비교하고, 환율우대 혜택을 가장 많이 받을 수 있는 곳이 어디인지 직접 정리해 본다.  1. 들어가기 전: 환전수수료와 환율우대란? 우리가 달러를 살 때, 기준이 되는 '매매기준율'에 증권사가 수수료를 붙여서 판매한다. 이 수수료를 '환전 스프레드' 라고 하며, 보통 1달러당 5원~10원 정도다. '환율우대' 란 이 환전 스프레드를 깎아주는 것을 의미한다. 예를 들어, 환율우대 95% 는 내가 내야 할 환전수수료의 95%를 할인해준다는 뜻이다. 당연히 우대율이 높을수록 우리에게 유리하며, 환율우대 100%는 환전수수료가 0원 이라는 의미다. 2. 증권사별 환전 우대율 및 이용 시간 비교 (우대율 높은 순) ※ 중요: 아래 정보는 신규 또는 휴면 고객 대상 이벤트 기준으로, 2025년 8월 말 현재 시점으로 재확인한 내용이다. 이벤트 기간이나 조건은 언제든 변경될 수 있으니, 거래 전 반드시 해당 증권사에 직접 확인해야 한다. 참고로 난 한투로 미국 주식을 하고 있는데 90% 우대를 받고 있다. 아직은 작은 금액이라 무시하고 있지만 억단위로 넘어가면 위 증권사 계좌로 넘어갈 계획이다. 3. 서비스별 심층 분석 및 투자 전략 3.1 조건부 최강자: NH투자증권 (나무증권)  NH투자증권은 '원화주문' 이라는 독특한 서비스를 통해 차별점을 가진다. 핵심 장점: 24시간 100% 자동 환율우대: 미국 주식 시장이 열리...