Skip to main content
이 문서는 한국어 RAG 최적화 의 일부입니다.

3. Kiwi 기반 BM25 Retriever

기본 BM25는 공백 기반으로 텍스트를 분절하므로, 한국어에서는 조사가 붙은 채로 토큰화됩니다. Kiwi로 형태소 분석 후 명사/동사/외국어만 추출 하면 검색 품질이 크게 향상됩니다.
from kiwipiepy import Kiwi
from langchain_community.retrievers import BM25Retriever

kiwi = Kiwi()

def kiwi_tokenize(text: str) -> list[str]:
    """Kiwi 형태소 분석기로 명사/동사/형용사/외국어만 추출"""
    tokens = kiwi.tokenize(text)
    # NNG(일반명사), NNP(고유명사), VV(동사), VA(형용사), SL(외국어)
    return [t.form for t in tokens if t.tag in ('NNG', 'NNP', 'VV', 'VA', 'SL')]

# Kiwi 토크나이저를 사용하는 BM25 Retriever
bm25_retriever = BM25Retriever.from_documents(
    documents,
    preprocess_func=kiwi_tokenize,
    k=5
)

# "데이터브릭스에서 RAG를 구축하는 방법" →
# kiwi_tokenize → ["데이터브릭스", "RAG", "구축", "방법"]
results = bm25_retriever.invoke("데이터브릭스에서 RAG를 구축하는 방법")

Kiwi + Ensemble Retriever

한국어 RAG에서 가장 효과적인 조합은 Kiwi BM25 + Dense (Vector Search) 앙상블입니다:
from langchain.retrievers import EnsembleRetriever
from langchain_databricks import DatabricksVectorSearch

# Kiwi 기반 BM25
bm25_retriever = BM25Retriever.from_documents(
    documents, preprocess_func=kiwi_tokenize, k=5
)

# Databricks Vector Search (다국어 임베딩)
vs_retriever = DatabricksVectorSearch(
    endpoint="vs-endpoint",
    index_name="catalog.schema.ko_docs_index",
    columns=["content", "source"]
).as_retriever(search_kwargs={"k": 5})

# 앙상블
ensemble = EnsembleRetriever(
    retrievers=[bm25_retriever, vs_retriever],
    weights=[0.4, 0.6]
)
한국어 전문 용어가 많은 도메인(법률, 의료 등)에서는 BM25 가중치를 0.5~0.6으로 높이면 정확한 용어 매칭이 강화됩니다.

RRF (Reciprocal Rank Fusion) 직접 구현

LangChain 없이 BM25와 Vector Search 결과를 직접 결합하는 방법입니다. 각 검색기의 점수 스케일이 달라도 순위(rank) 기반으로 통합하므로 별도의 정규화가 필요 없습니다.
from kiwipiepy import Kiwi
from rank_bm25 import BM25Okapi
import numpy as np

kiwi = Kiwi()

def korean_tokenize(text):
    """Kiwi로 의미 형태소(명사, 동사, 형용사)만 추출"""
    tokens = kiwi.tokenize(text)
    return [t.form for t in tokens if t.tag in ('NNG', 'NNP', 'VV', 'VA')]

def reciprocal_rank_fusion(vector_results, bm25_results, k=60):
    """두 검색기의 결과를 RRF 알고리즘으로 결합합니다.

    Args:
        vector_results: 벡터 검색 결과 (doc_id 리스트, 관련도순 정렬)
        bm25_results:   BM25 검색 결과 (doc_id 리스트, 관련도순 정렬)
        k: 순위 완화 파라미터 (기본값 60, 논문 권장값)
    """
    scores = {}
    for rank, doc_id in enumerate(vector_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    for rank, doc_id in enumerate(bm25_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# 사용 예시
docs = {
    "doc_001": "Databricks는 데이터와 AI를 위한 통합 레이크하우스 플랫폼입니다",
    "doc_002": "Delta Lake는 ACID 트랜잭션과 스키마 관리를 지원합니다",
    "doc_003": "Unity Catalog로 테이블, 모델, 볼륨의 접근 권한을 관리합니다",
    "doc_004": "Vector Search로 임베딩 기반 유사도 검색을 수행합니다",
}

doc_ids = list(docs.keys())
tokenized = [korean_tokenize(text) for text in docs.values()]
bm25 = BM25Okapi(tokenized)

query = "데이터 플랫폼에서 접근 권한 관리"
bm25_scores = bm25.get_scores(korean_tokenize(query))
bm25_ranked = [doc_ids[i] for i in np.argsort(bm25_scores)[::-1]]

# 벡터 검색 결과 (Databricks Vector Search API로 조회)
vector_ranked = ["doc_001", "doc_004", "doc_003", "doc_002"]

fused = reciprocal_rank_fusion(vector_ranked, bm25_ranked, k=60)
for doc_id, score in fused[:5]:
    print(f"[RRF: {score:.4f}] {doc_id}: {docs[doc_id]}")
  • RRF의 k 파라미터: 기본값 60은 원래 RRF 논문의 권장값입니다. k를 줄이면(예: 10) 상위 순위 문서에 더 큰 가중치가 부여됩니다.
  • 가중 RRF: BM25와 Vector Search에 서로 다른 가중치를 주고 싶다면, 각각의 RRF 점수에 가중치를 곱하면 됩니다 (예: BM25에 0.4, Vector에 0.6).

4. 한국어 청킹 전략

전략설명장점단점
문장 기반 (KSS)한국어 문장 경계 인식자연스러운 분절문장이 짧으면 청크가 너무 작음
형태소 기반Kiwi로 의미 단위 분절정확한 의미 보존구현 복잡
Semantic 청킹임베딩 유사도 기반 경계 결정의미 전환점 자동 감지연산 비용 높음
Recursive + 한국어 구분자한국어 종결어미 기반 분절범용적, 구현 간단구분자 설계 필요

KSS (Korean Sentence Splitter) 활용

# 설치: pip install kss
import kss

text = """Databricks는 데이터와 AI를 위한 통합 플랫폼입니다.
Delta Lake를 기반으로 데이터 레이크하우스 아키텍처를 제공합니다.
Unity Catalog로 데이터 거버넌스를 통합 관리할 수 있습니다."""

sentences = kss.split_sentences(text)
for s in sentences:
    print(s)

RecursiveCharacterTextSplitter + 한국어 구분자

from langchain.text_splitter import RecursiveCharacterTextSplitter

korean_splitter = RecursiveCharacterTextSplitter(
    separators=[
        "\n\n",    # 문단 구분
        "\n",      # 줄바꿈
        "다. ",    # 평서문 종결
        "요. ",    # 존댓말 종결
        "까? ",    # 의문문 종결
        ". ",      # 일반 마침표
        " ",       # 공백
    ],
    chunk_size=500,
    chunk_overlap=50,
    length_function=len,
)

chunks = korean_splitter.split_text(long_korean_text)
참고 한국어에서 RecursiveCharacterTextSplitter를 사용할 때는 종결어미(다. , 요. )를 구분자에 추가하면 문장 중간에서 잘리는 것을 방지할 수 있습니다.

이전: 과제 & Kiwi 형태소 분석

한국어 RAG의 구조적 어려움과 형태소 분석 기초

다음: 임베딩 모델 & 베스트 프랙티스

한국어 임베딩 모델 선택, Re-ranking, 트러블슈팅