> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sifi.life/llms.txt
> Use this file to discover all available pages before exploring further.

# BM25 검색 & 청킹 전략

> 이 문서는 **[한국어 RAG 최적화](/blog/guides/rag/korean-nlp)** 의 일부입니다.

## 3. Kiwi 기반 BM25 Retriever

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

```python theme={null}
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)** 앙상블입니다:

```python theme={null}
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)** 기반으로 통합하므로 별도의 정규화가 필요 없습니다.

```python theme={null}
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) 활용

```python theme={null}
# 설치: pip install kss
import kss

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

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

### RecursiveCharacterTextSplitter + 한국어 구분자

```python theme={null}
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`를 사용할 때는 종결어미(`다. `, `요. `)를 구분자에 추가하면 문장 중간에서 잘리는 것을 방지할 수 있습니다.

***

<CardGroup cols={2}>
  <Card title="이전: 과제 & Kiwi 형태소 분석" href="korean-nlp-basics">
    한국어 RAG의 구조적 어려움과 형태소 분석 기초
  </Card>

  <Card title="다음: 임베딩 모델 & 베스트 프랙티스" href="korean-nlp-embedding">
    한국어 임베딩 모델 선택, Re-ranking, 트러블슈팅
  </Card>
</CardGroup>
