logo

키워드 검색

TF-IDF와 BM25가 검색어에 가중치를 주는 원리를 이해하고, 마인드몰 안내서에서 질문과 일치하는 청크를 찾는다.

  • 검색기(retriever)는 질문에 관련된 청크를 찾아 순서대로 돌려줌. 키워드 검색은 질문과 청크에 같은 단어나 토큰이 있는지 보고 점수를 매김.
  • 단어마다 차원을 하나씩 두면 한 청크에서 대부분의 값은 0. 이처럼 일부 차원만 값이 있어 희소 검색(sparse retrieval)이라고 부름.

청크와 토큰

앞 실습에서 읽은 안내서(guide)의 각 문단에 절 제목을 붙임. 검색할 때 문단 안의 단어와 제목의 단어를 함께 사용.

def chunk_with_heading(text):
    chunks = []
    for section in text.split("\n## ")[1:]:  # 머리말 제외
        title, body = section.split("\n", 1)  # 첫 줄은 절 제목
        chunks += [f"[{title}] {p.strip()}" for p in body.split("\n\n") if p.strip()]
    return chunks

chunks = chunk_with_heading(guide)
len(chunks)
실행 결과
22

토큰화: 한국어는 조사와 어미가 붙어 "반품"과 "반품하면"이 다른 단어가 됨. 여기서는 어절을 두 글자씩 겹쳐 자르는 바이그램(bigram)으로 부분 일치를 잡음. 형태소 분석기를 써도 됨.

def tokenize(text):  # 어절마다 두 글자씩 겹쳐 자름
    tokens = []
    for word in text.split():
        tokens += [word[i:i + 2] for i in range(max(1, len(word) - 1))]
    return tokens

tokenize("반품하면 배송비")
실행 결과
['반품', '품하', '하면', '배송', '송비']

역 인덱스

  • chunks[7]을 열면 7번 청크에 어떤 단어가 있는지 알 수 있음. 검색할 때는 반대로 ‘배송’이라는 토큰을 보고 이 토큰이 들어 있는 청크 번호를 찾아야 함.
  • 역 인덱스(inverted index): 이 방향의 조회를 위해 토큰마다 청크 번호 목록을 미리 저장한 구조.
from collections import defaultdict

postings = defaultdict(set)
for chunk_id, chunk in enumerate(chunks):
    for token in tokenize(chunk):
        postings[token].add(chunk_id)  # 같은 청크에서 반복된 토큰은 한 번만 등록

sorted(postings["배송"])
실행 결과
[2, 3, 6, 7, 8, 10, 15]

‘배송’은 7개 청크에, ‘제주’는 7번 청크 하나에 등장. 두 토큰을 모두 포함해야 한다면 두 목록의 교집합을 구함.

sorted(postings["제주"] & postings["배송"])
실행 결과
[7]

검색할 때는 22개 청크를 다시 읽는 대신 질문에 나온 토큰의 목록만 조회. 위 예시는 청크 번호만 저장하지만, 검색 엔진은 토큰의 출현 횟수나 위치도 저장할 수 있음. 횟수는 관련도 점수에, 위치는 두 단어가 차례로 붙어 있는지 확인하는 구문 검색에 쓰임.

랭킹

  • 후보가 여럿이면 TF-IDF나 BM25 같은 기준으로 순위를 매김.

TF-IDF

  • 단어 빈도(TF): 한 청크 안에서 토큰이 나온 횟수. 청크 d에서 토큰 t가 나온 횟수를 ft,d​
  • 문서 빈도(DF): 그 토큰을 한 번이라도 포함한 청크의 수. 이 토큰이 있는 청크 수를 nt​. (DF가 높을수록 토큰의 중요성이 낮아짐)
  • 역문서 빈도(IDF): 여러 청크에 두루 나오는 토큰의 가중치를 낮춤.
  • 전체 청크 수를 N

토큰 하나의 가중치:

TF-IDF(t,d)=ft,d​ln(nt​N​)

질문에 맞는 청크의 순위를 매기는 한 가지 방법은 질문 토큰의 가중치를 더하는 것. q는 질문을 토큰화한 목록이며, 색인에 없는 토큰은 합에서 제외.

ScoreTF-IDF​(q,d)=t∈q∑​TF-IDF(t,d)

안내서 22개 청크에서 ‘제주’와 ‘배송’을 비교. 아래는 위 바이그램으로 센 값이며 IDF에는 자연로그를 사용.

토큰 7번 청크 안의 횟수(TF) 이 토큰이 있는 청크 수(DF) IDF 7번 청크의 TF-IDF
제주 1 1 3.09 3.09
배송 5 7 1.15 5.73

‘제주’는 한 번만 나와도 희귀해서 가중치가 큼. ‘배송’은 한 번당 가중치는 작지만 이 청크에서 다섯 번 반복되어 최종 가중치가 더 큼.

BM25

  • BM25(Best Matching 25)는 질문에 들어 있는 각 토큰이 청크에 얼마나 잘 맞는지 점수를 구해 더함. 드문 검색어의 일치에 큰 가중치를 주고, 반복 횟수와 청크 길이도 반영.
BM25(q,d)=t∈q∑​IDFBM25​(t)ft,d​+k1​(1−b+bLˉ∣d∣​)(k1​+1)ft,d​​ IDFBM25​(t)=ln(nt​+0.5N−nt​+0.5​)
  • 빈도 포화. 토큰이 반복될수록 추가 점수가 작아짐. 이미 여러 번 나온 단어는 한 번 더 나와도 새로운 단서가 적기 때문.
    • k1​이 작을수록 반복에 따른 점수가 빨리 포화 (기본값 k1​=1.5)

청크 길이가 일정할 때 토큰이 반복될수록 BM25 점수 기여분이 늘어나지만, 한 번 더 등장할 때의 증가 폭은 작아진다.

  • 길이 보정. 다른 조건이 같을 때 짧은 청크에서 토큰의 점수가 더 큼. 전체 청크의 평균 길이를 기준으로, 긴 청크에 단어가 더 자주 나올 기회를 보정.
    • ∣d∣: 청크의 토큰 수
    • Lˉ: 전체 청크의 평균 토큰 수
    • b가 클수록 청크 길이를 강하게 보정 (기본값 b=0.75)

토큰 등장 횟수가 같을 때 청크 길이가 길어질수록 해당 토큰의 BM25 점수 기여분이 줄어든다.

!pip install -q rank_bm25
import numpy as np
from rank_bm25 import BM25Okapi

bm25 = BM25Okapi([tokenize(c) for c in chunks])  # 청크마다 토큰 목록으로 색인

def search_bm25(query, k=3):  # 상위 k개의 (번호, 점수). 점수 0은 제외
    scores = bm25.get_scores(tokenize(query))
    top = np.argsort(-scores)[:k]
    return [(int(i), round(float(scores[i]), 2)) for i in top if scores[i] > 0]

search_bm25("제주도 배송비")
실행 결과
[(7, 8.39), (10, 3.27), (15, 2.4)]
  • 1위인 7번 청크는 제주도 추가 배송비를 다룬 배송 절의 문단. 7번 청크는 69토큰, 전체 평균은 45토큰. ‘제주도 배송비’에서 만든 ‘제주’, ‘주도’, ‘배송’, ‘송비’의 점수를 위 식으로 더하면 약 8.39로, 실습 결과와 일치.

데이터베이스와 검색 엔진에서의 구현

  • 전문 검색(full-text search): 본문과 검색어를 토큰 단위로 분석해 조건에 맞는 행을 찾는 기능.
    • 보통 토큰마다 해당 행의 목록을 저장하는 역 인덱스로 검색 속도를 높임.
    • 부가적 기능들:
      • 여러 검색어를 조합(예: ‘제주’와 ‘배송’을 모두 포함)
      • 특정 구문 찾기(예: ‘추가 배송비’가 이 순서로 붙어서 등장)
      • 결과를 관련도순으로 정렬
제품 기본 기능 선택 기능
PostgreSQL 내장 전문 검색 유사 문자열 검색(pg_trgm), BM25(pg_search)
SQLite — FTS5 전문 검색(빌드에 따라 내장)
MySQL (InnoDB) FULLTEXT 전문 검색 한국어 등에 쓰는 ngram 파서
SQL Server — Full-Text Search 선택 설치
Oracle Database — Oracle Text 전문 검색
Elasticsearch 전문 검색과 BM25 순위 계산 한국어 형태소 분석(analysis-nori)

제품별 색인 구조와 검색 문법

부분 문자열을 자주 찾는다면 SQLite FTS5에 tokenize='trigram'을 지정해 연속 세 글자를 역 인덱스로 만들 수 있음. 이 가상 테이블에서는 LIKE '%배송비%' 조건도 ‘배송비’ 토큰으로 후보 행을 좁힌 뒤 원래 패턴을 확인. FTS5 트라이그램 토크나이저

  • PostgreSQL: to_tsvector로 본문을 정규화하고 GIN 인덱스를 만들면 검색어가 들어 있는 행을 빠르게 찾을 수 있음. 검색 조건은 tsquery로 표현하고 @@로 대조. 철자가 비슷한 문자열을 찾으려면 pg_trgm을, BM25 순위가 필요하면 ParadeDB의 pg_search를 사용.
  • SQLite: FTS5가 빌드에 포함되어 있으면 바로 쓰고, 별도 모듈로 빌드했다면 확장을 로드. CREATE VIRTUAL TABLE docs USING fts5(body)로 만든 가상 테이블은 토큰마다 행 번호(rowid)를 기록. 앞 실습의 바이그램을 공백으로 구분해 저장하면 WHERE docs MATCH '제주 AND 배송'으로 두 토큰이 모두 있는 행을 찾을 수 있음.
  • MySQL (InnoDB): 전문 인덱스에 단어별 문서 ID와 단어의 위치를 저장하고, MATCH(body) AGAINST(...)로 검색. 인덱스를 만들 때 내장 ngram 파서를 선택하면 한국어 텍스트를 일정한 글자 수 단위로 나눌 수 있음.
  • SQL Server: Full-Text Search는 언어별로 단어를 분리해 각 단어가 나온 문서 ID를 색인. CONTAINS로 특정 단어나 구문을 포함하는 행을 검색.
  • Oracle Database: Oracle Text의 CTXSYS.CONTEXT 인덱스는 토큰마다 해당 토큰이 나온 행 목록을 저장. CONTAINS로 텍스트 열을 검색.
  • Elasticsearch: Lucene 기반 역 인덱스에 text 필드에서 추출한 토큰과 문서 ID를 저장. match 질의는 검색어도 토큰으로 나누어 문서를 찾고, 기본적으로 BM25 점수순으로 결과를 반환. Nori 플러그인을 추가하면 한국어 형태소 단위로 분석할 수 있음.

퀴즈

문제 1 / 7맞음: 0힌트: 0틀림: 0채점중: 0남음: 7

같은 청크에 두 토큰이 한 번씩 나온다면, 기본 TF-IDF가 더 큰 토큰은?

  • ○더 적은 수의 청크에 등장하는 토큰
  • ○더 많은 청크에 등장하는 토큰
  • ○그 청크에서 먼저 나온 토큰
  • ○그 청크에서 나중에 나온 토큰

퀴즈를 풀려면 대화형 기능을 불러와야 합니다.