全文検索エンジン ── 転置インデックスとランキング
扱うCS概念:転置インデックス、TF-IDF、BM25、Segmentマージ(LSM-Tree)、シャーディング

この章で何ができるようになるか:全文検索エンジンの内部構造(インデックス構築・クエリ処理・ランキング)を説明できるようになる。Elasticsearch / OpenSearch の設計思想と、RDBMS の LIKE 検索との違いを説明できるようになる。
問題設定
「10億件のウェブページから “分散システム 設計” に関連するものを100ms以内に返せ」
RDBMS でこれをやろうとすると:
-- ❌ 全文LIKE検索(フルスキャン)
SELECT * FROM pages WHERE content LIKE '%分散システム%' AND content LIKE '%設計%';
-- 10億行に対してフルテーブルスキャン → 数時間かかる
なぜ遅いか:LIKE 検索はインデックスを使えない(前方一致インデックスは使えるが、中間一致は不可)。
全文検索エンジンはこれを「事前にインデックスを構築しておく」ことで解決する。
転置インデックス(Inverted Index)
検索エンジンの核心。単語(トークン)から文書IDへのマッピングを事前に作っておく。
文書1: "分散システムの設計と実装"
文書2: "データベースの設計パターン"
文書3: "分散キャッシュの実装"
転置インデックス:
"分散" → [文書1, 文書3]
"システム" → [文書1]
"設計" → [文書1, 文書2]
"実装" → [文書1, 文書3]
"データベース" → [文書2]
"パターン" → [文書2]
"キャッシュ" → [文書3]
クエリ "分散システム 設計" の処理:
"分散" → [1, 3]
"システム" → [1]
"設計" → [1, 2]
AND 演算(全単語を含む文書):
[1, 3] ∩ [1] ∩ [1, 2] = [1]
→ 文書1 が返る
ポスティングリスト(Posting List):各単語に対応する(文書ID, 出現位置)のリスト。
posting_list = {
"分散": [(doc_id=1, positions=[0, 12]), (doc_id=3, positions=[0])],
"設計": [(doc_id=1, positions=[8]), (doc_id=2, positions=[7])],
}
出現位置(positions)を持つことで「“分散” の直後に “システム” が来る」というフレーズ検索が可能になる。
インデックス構築のパイプライン
graph TD
Crawl[クローラー<br/>Web ページ収集] --> Parse[HTML パーサー<br/>テキスト抽出]
Parse --> Tokenize[トークナイザー<br/>単語分割・正規化]
Tokenize --> Index[インデックスビルダー<br/>転置インデックス構築]
Index --> Segment[セグメントファイル<br/>ディスクに書き出し]
Segment --> Merge[セグメントマージ<br/>定期的に統合]
Merge --> SearchIndex[検索インデックス<br/>クエリ処理]
トークナイズ
日本語の場合、スペースで単語が区切られないため形態素解析が必要だ。
# MeCab(日本語形態素解析器)を使ったトークナイズ
import MeCab
def tokenize_japanese(text: str) -> list[str]:
tagger = MeCab.Tagger()
node = tagger.parseToNode(text)
tokens = []
while node:
feature = node.feature.split(',')
pos = feature[0] # 品詞
# 名詞・動詞・形容詞のみをインデックス対象に
if pos in ['名詞', '動詞', '形容詞']:
base_form = feature[6] if feature[6] != '*' else node.surface
tokens.append(base_form)
node = node.next
return tokens
# "分散システムの設計" → ["分散", "システム", "設計"]
英語では Porter Stemmer などで語尾変化を吸収する:
"running" → "run"
"distributed" → "distribut"
"designs" → "design"
ランキング:TF-IDF から BM25 へ
「関連する文書」は複数ある。どれを上位に表示するか。
TF-IDF(Term Frequency - Inverse Document Frequency)
TF(Term Frequency):その文書内での単語の出現頻度
TF(t, d) = (文書d内の単語tの出現回数) / (文書d内の全単語数)
IDF(Inverse Document Frequency):単語の希少性
IDF(t) = log(全文書数 / 単語tを含む文書数)
TF-IDF スコア:TF × IDF
import math
from collections import Counter
def compute_tf_idf(corpus: list[list[str]], query_terms: list[str]):
N = len(corpus)
scores = []
for doc_id, doc in enumerate(corpus):
score = 0.0
tf_counter = Counter(doc)
for term in query_terms:
# TF: 文書内の出現頻度
tf = tf_counter[term] / len(doc) if len(doc) > 0 else 0
# DF: 何文書にこの単語が出現するか
df = sum(1 for d in corpus if term in d)
# IDF: 希少な単語ほど高いスコア
idf = math.log((N + 1) / (df + 1)) + 1 # +1 でゼロ除算回避
score += tf * idf
scores.append((doc_id, score))
return sorted(scores, key=lambda x: -x[1]) # スコア降順
IDF の直感:「の」「は」「が」のような助詞は全文書に出現するため IDF が低い。「量子コンピューター」のような専門用語は少数の文書にしか出ないため IDF が高い。
BM25(Best Matching 25)
現代の検索エンジンで標準的に使われるランキングアルゴリズム。TF-IDF の改良版で、文書長のバイアスを補正する。
def bm25_score(term: str, doc: list[str], corpus: list[list[str]],
k1: float = 1.2, b: float = 0.75) -> float:
"""
k1: TF の飽和パラメーター(高いほど出現頻度の影響が強い。標準デフォルト=1.2)
b: 文書長の正規化強度(0=正規化なし, 1=完全正規化)
"""
N = len(corpus)
df = sum(1 for d in corpus if term in d)
idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
tf = doc.count(term)
doc_len = len(doc)
avg_doc_len = sum(len(d) for d in corpus) / N
# 文書長による TF の正規化
# 長い文書ほど単語が多く出るバイアスを補正
tf_normalized = tf * (k1 + 1) / (tf + k1 * (1 - b + b * doc_len / avg_doc_len))
return idf * tf_normalized
TF-IDF との違い:
TF-IDF の問題:
文書A(100単語): "設計" が 5回 → TF = 0.05
文書B(1000単語): "設計" が 10回 → TF = 0.01
→ 文書A の方が "設計" に特化しているのに、文書Bの方がスコアが高くなりうる
BM25 の改善:
文書長で TF を正規化 → 短い文書でも公平に評価される
TF の飽和:同じ単語が100回出ても10回の10倍にはならない(収穫逓減)
セグメントとマージ(LSM-Tree)
大量の文書を継続的にインデックスに追加する際、毎回全インデックスを再構築するのは非効率だ。Elasticsearch(Lucene ベース)はセグメントという単位でこれを解決する。
graph TD
NewDocs[新規文書] --> Buffer[メモリバッファ]
Buffer -->|閾値を超えたら| Seg1[Segment 1<br/>不変・小]
Buffer -->|閾値を超えたら| Seg2[Segment 2<br/>不変・小]
Buffer -->|閾値を超えたら| Seg3[Segment 3<br/>不変・小]
Seg1 & Seg2 & Seg3 -->|バックグラウンドマージ| Seg4[Segment 4<br/>不変・大]
Seg4 -->|さらにマージ| Seg5[Segment 5<br/>不変・より大]
セグメントは不変(Immutable):一度書き出したら変更しない。
削除のしかた:実際には削除せず「削除済みフラグ」を別ファイル(.del ファイル)に記録する。クエリ結果から除外するのはフラグを見て行い、実際のストレージ解放はマージ時に行う。
なぜこの設計か:
- ランダム書き込みを避け、シーケンシャル書き込みに変換(高速)
- セグメント内部はソート済みのため、マージは O(N) でできる(マージソート)
- 検索はすべてのセグメントを並列に検索して結果をマージ
これは **LSM-Tree(Log-Structured Merge-Tree)**のアイデアそのものだ。Cassandra、LevelDB、RocksDB も同じ原理を使っている。
分散検索:シャーディングとレプリカ
1台のサーバーに収まらない規模(100億文書など)では、インデックスを分割する必要がある。
graph TD
Query[クエリ] --> Coordinator[コーディネーターノード]
Coordinator --> Shard1[シャード1<br/>文書0〜10億]
Coordinator --> Shard2[シャード2<br/>文書10〜20億]
Coordinator --> Shard3[シャード3<br/>文書20〜30億]
Shard1 --> R1[各シャードの<br/>上位10件を返す]
Shard2 --> R1
Shard3 --> R1
R1 --> Coordinator
Coordinator --> Merge2[全体の上位10件に<br/>再ランキング]
Merge2 --> Result[結果を返す]
分散ランキングの問題:各シャードは自分のシャード内のスコアでランキングするが、IDF の計算に「全文書数」が必要だ。シャード1は全体の文書数を知らない。
解決策:
- グローバル IDF を事前計算:全シャードから DF(単語の出現文書数)を集計し、グローバル IDF テーブルを作って全シャードで共有
- 2フェーズクエリ:第1フェーズで各シャードのスコアを収集 → 第2フェーズで正確な IDF でリランキング
ベクトル検索(近似最近傍探索)
2025年以降、LLM の埋め込み(embedding)と組み合わせたベクトル検索が普及している。キーワード検索では「同じ意味だが違う単語」を拾えない問題をベクトル類似度で解決する。
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
# 文書をベクトル化してインデックス
documents = [
"分散システムの設計パターン",
"マイクロサービスアーキテクチャ入門",
"猫の飼い方ガイド"
]
doc_vectors = model.encode(documents)
# クエリのベクトル化
query = "スケーラブルなシステムを作るには"
query_vector = model.encode([query])[0]
# コサイン類似度でランキング
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
scores = [(i, cosine_similarity(query_vector, dv)) for i, dv in enumerate(doc_vectors)]
scores.sort(key=lambda x: -x[1])
# → 文書0(分散システム)が最も類似
# → キーワード "分散" が含まれなくても意味で検索できる
HNSW(Hierarchical Navigable Small World):ベクトル空間での近似最近傍探索を高速化するグラフ構造。完全探索は O(N) だが HNSW は O(log N) に近い速度で近似結果を返す。Elasticsearch、pgvector、Pinecone がこれを実装している。
まとめ
| 課題 | 解決策 | CS概念 |
|---|---|---|
| LIKE 検索が遅い | 転置インデックス | 事前計算 + 空間・時間トレードオフ |
| 関連度のランキング | BM25(文書長補正あり) | TF-IDF の改良 |
| 継続的なインデックス更新 | セグメント + バックグラウンドマージ | LSM-Tree |
| 削除の扱い | ソフトデリート(.del ファイル) | 不変データ構造 |
| 大規模な水平スケール | シャーディング + グローバルIDF共有 | 分散集計 |
| 意味的類似検索 | ベクトル埋め込み + HNSW | 近似最近傍探索 |
次章(エピローグ)でシリーズ全体を振り返る。