Dense Embeddingはテキストの意味を数値ベクトルに変換して「意味が似ていれば近いベクトル」になる方式であり、BM25インデックスはテキストに含まれる単語自体の出現頻度と希少度でスコアを付ける方式だ。前者は「同義語や他の表現」を見つけることができ、後者は「まさにその単語を持つ文書」を見つけることができます。
選択理由:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
embeddings = model.encode(
texts,
batch_size=64,
normalize_embeddings=True, # L2 정규화 → 코사인 유사도 = 내적
)
L2正規化が重要な理由:
@)になるscores = embeddings @ query_embedding.T # (9586,) 벡터, <100ms
ここで、numpyはPythonの数値計算ライブラリで、大量の数値配列を素早く計算できます。上記の@演算子1行で9,586個のベクトルとの類似度を一度に計算するのがnumpyのおかげです。Dense embeddingだけでは正確なキーワードマッチングが弱い。 「甲木」を検索したが、意味的に似た「羊のオーラ」が上がる式だ。 正確な用語マッチングが重要なドメインではBM25が必須補完。
# 한국어: Kiwi 형태소 분석기 (명사/동사/형용사만 추출)
def tokenize_ko(text: str) -> list[str]:
kiwi = Kiwi()
tokens = []
for token in kiwi.tokenize(text):
if token.tag.startswith(("NN", "VV", "VA", "SL", "SH", "SN")):
min_len = 1 if token.tag.startswith("NN") else 2
if len(token.form) >= min_len:
tokens.append(token.form)
return tokens
# 중국어: jieba 분절 (불용어 제거)
def tokenize_zh(text: str) -> list[str]:
return [w.strip() for w in jieba.cut(text)
if w.strip() and w not in _ZH_STOP]
# 영어: regex + stopwords (100개)
def tokenize_en(text: str) -> list[str]:
words = re.findall(r"[a-zA-Z]+", text.lower())
return [w for w in words if w not in _ENGLISH_STOP and len(w) > 2]
韓国語の1文字名詞を許可する理由:
五行(金/水/木/火/土)、千間(甲/を/病/錠)など1文字名詞がドメインの核心用語である。一般NLPでは1文字名詞をノイズで除去するが、ここではmin_len = 1 if NN条件で名詞のみ許可。
ITサービスでの同じ問題:
ITドメインにも短い略語が重要な用語であることが多い:CI、CD、DB、VM、LB、DR、QAなど。英語のトークナイザーでlen(w) > 2フィルタを適用すると、これらの2文字の略語がすべて削除されます。ドメインに応じてトルクナイザーの最小長制限を調整するか、重要な略語を不用な例外リストに追加する必要があります。
JSONL(JSON Lines)は、1行に1つのJSONオブジェクトを格納する形式で、全体をメモリにアップロードせずに1行ずつストリーミング処理することができ、大量のチャンクデータを扱うのに適しています。通常のJSON配列([{...}, {...}])はファイル全体を解析する必要がありますが、JSONLは特定の行だけを読み取るか、末尾に追加するのが自由です。
data/
├── chunks.jsonl # 원본 청크 (chunk_id, source, page, lang, text)
├── chunks_meta.jsonl # 검색 결과 반환용 메타+텍스트
├── embeddings.npy # Dense 벡터 (9586 × 384, float32)
├── bm25_index.pkl # BM25Okapi 직렬화
├── tokenized_corpus.pkl # 사전 토큰화 코퍼스 (검색 시 재토큰화 방지)
└── .last_build_count # 마지막 빌드 시 청크 수 (변경 감지용)
フルリビルド時間:9,586チャンク基準約3〜5分(M1 Macローカル)
쿼리: "갑목 인월 용신 조후"
│
┌──────────┼──────────┐
▼ │ ▼
┌──────────┐ │ ┌──────────┐
│ BM25 │ │ │ Dense │
│ top-K │ │ │ top-K │
└────┬─────┘ │ └────┬─────┘
│ │ │
└─────┬─────┘─────────┘
│
합집합 (K=max(20, top_k×4), 기본 top_k=5 시 최대 40개)
│
▼
┌─────────────────────┐
│ Stage 2: Reranker │
│ bge-reranker-v2-m3 │
│ (Cross-Encoder) │
└──────────┬──────────┘
│
top-K 결과 반환
BM25とDense検索は基本的に異なる方法であり、**クエリの性質によってどちらがより効果的であるかが異なります。**固定比率(例:BM25 40%+ Dense 60%)で合計すると、特定の種類のクエリで最適ではない結果が得られます。
問題を具体的に見てみましょう:
| クエリタイプ | BM25がうまくやっていること | Denseがうまくやっていること | 固定率の問題 |
|---|---|---|---|
"窮通寶鑑 甲木 寅月"(漢字キーワード) |
正確にこの漢字を含む文書を探す | 意味的に似た韓国語の解釈文をインポート(ノイズ) | Denseの割合が高いと、奇妙な韓国語の解釈が上位に上がる |
"갑목 일간이 봄에 태어났을 때 보충해야 할 기운"(記述型) |
「甲冑」、「春」の単語のみマッチングして断片的結果 | 文全体の意味を把握し、「首元気が旺盛な時に金に調節」といった答えを探す | BM25比重が高いと、関連性の低い「甲冑」を含む文書がトップに上がる |
"도화살" (短い専門用語) |
「道矢」が正確にある文書を探す | 「図矢」埋め込みが「恋愛」、「魅力」のような一般的な意味でもマッチング | 片側に偏ると精度や多様性を失う |
このプロジェクトの適応型重み:
def _adaptive_weights(query, lang):
hanja_ratio = (쿼리 내 한자 비율)
domain_term_count = (도메인 사전에 매칭된 용어 수)
if hanja_ratio > 0.3: # 한자 쿼리
return 0.6, 0.4 # BM25↑: 한자는 정확한 글자 매칭이 핵심
elif len(query) <= 8 and domain_term_count >= 1: # 짧은 전문용어
return 0.5, 0.5 # 균등: 양쪽 모두 필요
elif len(query) > 20 and domain_term_count == 0: # 긴 서술형
return 0.25, 0.75 # Dense↑: 의미 파악이 핵심
else:
return 0.4, 0.6 # 기본: Dense 약간 우세
プロダクションプレゼント:
適応型の重みは、社主名理学だけに必要なものではない。例えば:
GET /api/v2/users→BM25強化、「ユーザーリストのインポート方法」→Dense強化クエリの性質を実行時に分析して重みを自動調整すれば、さまざまなクエリパターンにすべて対応することができる。
このプロジェクトの最も重要な技術。韓国語クエリで中国語の原発を見つけることができるデバイス:
# 143개 키(209쌍)의 한↔漢 매핑 테이블 (일부)
_KOREAN_TO_HANJA = {
"갑목": ["甲木"],
"용신": ["用神"],
"도화살": ["桃花煞", "桃花"],
"합충형파해": ["合沖刑破害", "合冲刑破害"], # 정체자+간체자 모두 포함
"인신충": ["寅申沖", "寅申冲"],
...
}
クエリ処理プロセス:
입력 쿼리: "갑목 인월 용신"
↓ Kiwi 토큰화
토큰: ["갑목", "인", "월", "용신"]
↓ 복합 도메인 용어 추출 (형태소 분석 우회)
추가: ["갑목", "용신"] # 이미 포함됨 확인
↓ 한→漢 확장
확장: ["갑목", "인", "월", "용신", "甲木", "寅", "用神"]
↓ BM25 검색 (확장된 토큰으로)
ITサービスと同じパターン:
IT環境でも同じ意味の用語がハングル/英語/略語で混用される。クエリ拡張テーブルがないと、「Kubernetes」で検索したときに「Kubernetes」または「K8s」で書かれた文書が見つかりません。
_IT_TERM_EXPANSION = {
"쿠버네티스": ["Kubernetes", "K8s", "k8s"],
"도커": ["Docker", "docker"],
"로드밸런서": ["Load Balancer", "LB", "L4", "L7"],
"배포": ["deploy", "deployment", "릴리즈", "release"],
"장애": ["incident", "outage", "장애 대응", "postmortem"],
"인증": ["authentication", "auth", "OAuth", "SSO"],
"캐시": ["cache", "Redis", "Memcached", "CDN"],
"모니터링": ["monitoring", "Grafana", "Prometheus", "옵저버빌리티", "observability"],
...
}
このテーブルひとつで、「配布ロールバック方法」という韓国語クエリが「deployment rollback procedure」で書かれた英語文書も見つけることができるようになる。ドメイン事前構築はワンタイムコストですが、検索再現率向上効果は永久的です。
複雑なドメイン用語の抽出が必要な理由:
Kiwiが「大運」を「大」+「運」に分離したり、「宮廷補感」を誤って分断する場合がある。事前に登録された複合語を型解析前に先にマッチングして保存:
def _extract_compound_terms(query: str) -> list[str]:
# 길이 내림차순 정렬 → 긴 용어 우선 매칭 (합충형파해 > 합충 > 합)
sorted_terms = sorted(_KOREAN_TO_HANJA.keys(), key=len, reverse=True)
found = []
for term in sorted_terms:
if len(term) >= 2 and term in query:
found.append(term)
return found
同じソースから複数のチャンクが上位に上がると、回答が偏ります。同じソースの2番目のチャンクから5%ずつ減衰:
# 같은 소스 2번째부터 5%씩 감쇠 (최대 15%)
penalty = min(seen * 0.05, 0.15)
adjusted = original_score * (1.0 - penalty)
例:梁湘润_명리전승반필기.pdfで5つのチャンクが上位10に入っても、多様性ペナルティで他のソースチャンクが割り込んで複数の視点の解釈を得ることができる。
ITサービスでの同じ問題:
「決済タイムアウト」を検索したとき、決済チームConfluenceスペースのチャンク10個が上位を独占すると、インフラチームやQAチームが作成した他の視点の文書を見逃す。ソース多様性ペナルティを適用すると、決済チーム文書3個+インフラチーム障害対応文書2個+QAテストシナリオ1個のようにバランスのとれた結果を得ることができる。