順位データベースのインデックス作成 — 1億件のデータから瞬時に検索できるツールを作成する
このトピックを終えると
教科書で学んだDBインデックス、ハッシュテーブル、二分探索を組み合わせて、数百万~数億件の変異/配列テーブルから、目的の項目を線形スキャンなしで即座に見つけ出すインデックスツールを自分で作成できるようになります。「DBにインデックスを適用したら、クエリが100倍速くなった」という言葉が、内部で何が起こっていたのかをコードを通して理解できるようになります。
この記事は教育用の一般的な例です。ゲノム変異テーブルの検索は、バイオインフォマティクスにおいて毎日行われる作業であるため、題材として採用しました。
「その位置の変異を調べて」——毎回全体を検索する問題
ゲノム変異データは、通常、以下のような表形式で表されます。染色体、位置、参照塩基、変異塩基、および付加情報。
chrom pos ref alt gene
chr1 12345 A G GENE_A
chr1 67890 C T GENE_B
chr7 55211 G A GENE_C
... (数百万~数十億行)ここでよくある質問は以下の2つです。
- 正確な検索: 「chr1の12345の位置に変異があるか?」——特定の1点。
- 範囲検索: 「chr1の10000~20000の範囲にあるすべての変異を教えて」——特定の区間。
単純な方法では、毎回最初から最後まで全体を検索します(線形スキャン)。
def find_linear(records, chrom, pos): hits = [] for r in records: if r["chrom"] == chrom and r["pos"] == pos: hits.append(r) return hits変異が1万件であれば、すぐに検索できます。しかし、全ゲノム(whole genome)の場合、変異は数百万~数十億件になります。1回の検索で全体を検索し、100個の質問をするには、全体を100回検索する必要があります。ここから「クエリがなぜこんなに遅いのか?」という問題が発生します。
データベースを使ったことがある人は知っています。このような場合、インデックスを作成します。しかし、インデックスは一体何をするのでしょうか?このブログ記事では、そのインデックスを基礎から作成します。
まずは完成品を見てみましょう(ブラックボックスを先に実行)
私たちが作るツールは、テーブルを一度スキャンしてインデックスを作成し、その後は検索を即座に処理します。
db = VariantIndex(records) # インデックスを1回構築
db.get("chr1", 12345) # 正確な検索 → 即時db.range("chr1", 10000, 20000) # 範囲検索 → 即時=== 検索性能(変異50万件) ===
正確な検索(線形スキャン):0.0180秒/件
正確な検索(ハッシュインデックス):0.0000009秒/件 ← 約2万倍
範囲検索(線形スキャン):0.0210秒/件
範囲検索(二分探索):0.0000254秒/件 ← 約800倍
インデックス構築(1回):0.32秒重要なのは「構築は一度だけ、検索は無限に高速に」です。インデックスの作成に少し時間を費やしますが、その後は検索が劇的に高速化します。これから、2種類のインデックスを作成して、この速度を実現します。
このツールはどのような部品で構成されているか(部品分解図)
バリアントインデックス(VariantIndex)
┌──────────────────────────────────────────────────────────────┐
│ [入力] テーブル読み込み ────────── 部品:ファイル入出力 │ ← 完成品として提供(ツール)
│ │ │
│ ┌───────┴────────┐ │
│ ▼ ▼ │
│ [正確検索] [範囲検索] │
│ ハッシュテーブル ソート + 二分探索 │
│ 部品:ハッシュテーブル 部品:二分探索 │ ← どちらも自作 ★
│ │ │ │
│ └───────┬────────┘ │
│ ▼ │
│ [意味] これがまさにDBインデックス ── 部品:DBインデックス │ ← 自作 ★
└──────────────────────────────────────────────────────────────┘| 部品 | どこで学んだか | このツールで何をするか |
|---|---|---|
| ファイル入出力 | string-and-file-io | バリアントテーブルの読み込み |
| ハッシュテーブル | hash-table | 正確な位置をO(1)で検索 |
| 二分探索 | binary-search | ソートされた位置で範囲をO(log n)で検索 |
| DBインデックス | db-index | 上記2つが「DBインデックス」の実体であることを繋ぐ |
📌 これらの概念を初めて見る場合は(上部のリンク)
新たに自作する概念はハッシュインデックス、二分探索、DBインデックスの3つ。ファイル読み込みはツールとして完成品として提供します。たったの3つです — 認知限界を超えません。
ステップ1:データ準備(完成済みのものを提供)
検索対象のデータを作成する部分はツールとして提供します。実際にはファイルから読み込みますが、ブラウザでの実習のためにリストとして生成します。
import random
random.seed(1)chroms = ["chr1", "chr2", "chr7"]
def make_records(n): recs = [] for i in range(n): recs.append({ "chrom": random.choice(chroms), "pos": random.randint(1, 1_000_000), "ref": random.choice("ACGT"), "alt": random.choice("ACGT"), "gene": f"GENE_{i % 500}", }) return recs
records = make_records(50000)assert len(records) == 50000assert set(r["chrom"] for r in records) <= {"chr1", "chr2", "chr7"}このrecordsが、私たちの「テーブル」です。これを使って、2つの方法でインデックスを作成します。
ステップ 2 — 正確検索用のハッシュインデックス ★ (ハッシュテーブル)
✍️ 自分で構築するセクション。 コンポーネント = ハッシュテーブル。目標: 「(染色体、位置) → 変異」を O(1) で検索できるようにする。
正確な検索の鍵は、前のプライマーで見たものと同じです — 辞書 (= ハッシュテーブル) に事前に格納しておけば、すぐに取り出すことができます。 ここでは、キーを (染色体、位置) のタプルにします。
🔎 ハッシュインデックスとは (ドロワー — ハッシュテーブル) 辞書に
キー → 値を格納しておくと、後でそのキーで 一瞬で 値を取り出すことができます (O(1))。全体を検索する必要はありません。「正確にこのキー」を検索するには、これよりも高速な方法はありません。これがデータベースの ハッシュインデックス です。
from collections import defaultdict
def build_hash_index(records): """(chrom, pos) → その位置の変異リスト。""" index = defaultdict(list) for r in records: index[(r["chrom"], r["pos"])].append(r) return index
hash_index = build_hash_index(records)
def get_exact(hash_index, chrom, pos): return hash_index.get((chrom, pos), [])
# 検証: ハッシュ検索の結果は、線形スキャン結果と完全に同じである必要があるsample = records[12345]linear = find_linear(records, sample["chrom"], sample["pos"])hashed = get_exact(hash_index, sample["chrom"], sample["pos"])assert hashed == linearassert len(get_exact(hash_index, "chr1", -999)) == 0 # 存在しない位置は空のリストfind_linear (全体検索) と get_exact (ハッシュ) の結果が 完全に同じ であることを assert で確認しました。答えは同じで、速度だけが異なります。ハッシュインデックスは、位置がいくつあっても、検索にかかる時間が一定です。
🤔 自己説明プロンプト キーを
(chrom, pos)のタプルにしました。もしposだけをキーとして使用すると、どのような問題が発生するでしょうか? (ヒント: 異なる染色体は同じposを持つ可能性があります。chr1:12345 と chr7:12345 は異なる変異です。)
3단계 — 範囲検索用の二分探索インデックス ★ (二分探索)
✍️ 自分で埋めるセクション。 コンポーネント = 二分探索。目標: 「この範囲の変異をすべて」を O(log n) で見つける。
ハッシュインデックスは「正確にこの位置」に対しては最強だが、「10000~20000 の間」に対しては無力である。ハッシュには順序がないため、範囲を抽出できないのだ。範囲検索には別の武器 — ソート + 二分探索が必要だ。
発想はこうだ。染色体ごとに位置を ソートしておけば、範囲の始点と終点を二分探索で 即座に 見つけ、その間を切り出すことができる。
🔎 二分探索とは (図 — binary-search) ソートされたデータから、半分ずつ絞り込んで探す。100万個でも約20回で済む (log₂ 1,000,000 ≈ 20)。辞書で単語を探すときに、真ん中を開いて前/後を判断するのと同じだ。Pythonの標準ライブラリ
bisectがこれを行う。
import bisect
def build_range_index(records): """chromごとに位置をソートする。(ソートされた pos 配列、それに対応するレコード配列)""" by_chrom = defaultdict(list) for r in records: by_chrom[r["chrom"]].append(r) index = {} for chrom, recs in by_chrom.items(): recs.sort(key=lambda r: r["pos"]) # 位置順にソート (構築時に1回) positions = [r["pos"] for r in recs] index[chrom] = (positions, recs) return index
range_index = build_range_index(records)
def get_range(range_index, chrom, start, end): if chrom not in range_index: return [] positions, recs = range_index[chrom] lo = bisect.bisect_left(positions, start) # start が入る位置 (O(log n)) hi = bisect.bisect_right(positions, end) # end の次の位置 return recs[lo:hi] # その間を一度に切り出す
# 検証: 二分探索の範囲の結果が、線形フィルタの結果と一致しなければならない (個数・内容)def range_linear(records, chrom, start, end): return [r for r in records if r["chrom"] == chrom and start <= r["pos"] <= end]
bs = get_range(range_index, "chr1", 100000, 200000)lin = range_linear(records, "chr1", 100000, 200000)assert len(bs) == len(lin)assert sorted(r["pos"] for r in bs) == sorted(r["pos"] for r in lin)# 境界を含むかの確認: start と end 自体も含まれなければならない (bisect_left/right の組み合わせ)assert all(100000 <= r["pos"] <= 200000 for r in bs)bisect_left/bisect_right の組み合わせが核心だ。この2つで範囲の両端のインデックスを O(log n) で見つけ、recs[lo:hi] でその間をまとめて取得する。線形フィルタが全体を走査する間に、二分探索は辞書を広げるように、数回で範囲を特定する。
🤔 自己説明プロンプト なぜ始点には
bisect_left、終点にはbisect_rightを使ったのだろうか? もし両方ともbisect_leftを使うと、endと 正確に同じ 位置の変異は結果に含まれるか、含まれないか? (境界条件が1つ、結果を変える。)
部品を一つに — 完成したインデックスクラス
2つのインデックスを1つのツールにまとめます。これが縮小版の DBインデックスです。
class VariantIndex: def __init__(self, records): self.records = records self.hash_index = build_hash_index(records) # 正確な検索用 self.range_index = build_range_index(records) # 範囲検索用
def get(self, chrom, pos): return get_exact(self.hash_index, chrom, pos)
def range(self, chrom, start, end): return get_range(self.range_index, chrom, start, end)
db = VariantIndex(records)
# 2つの検索は、どちらも線形スキャンと同じ結果になるはずs = records[999]assert db.get(s["chrom"], s["pos"]) == find_linear(records, s["chrom"], s["pos"])assert len(db.range("chr2", 0, 1_000_000)) == len(range_linear(records, "chr2", 0, 1_000_000))🔎 つまり、DBインデックスとは何か(ドローワー — db-index) DBに
CREATE INDEXを設定すると、DBは裏で私たちが今作ったものと まったく同じこと を行います。正確な検索にはハッシュインデックスを、範囲検索にはソートされた構造(B-tree、二分探索の兄弟)を事前に作成します。「インデックスを設定したら100倍速くなった」とは、全体スキャンがハッシュ/二分探索に置き換えられたという意味です。皆さんは、今、その魔法のような内部を実際に実装しました。
パフォーマンスの詳細分析 — なぜこれほど高速なのか
import time
records = make_records(200000)db = VariantIndex(records)targets = [(r["chrom"], r["pos"]) for r in random.sample(records, 200)]
# 完全一致検索:線形 vs ハッシュt0 = time.time()for c, p in targets: find_linear(records, c, p)linear_time = time.time() - t0
t0 = time.time()for c, p in targets: db.get(c, p)hash_time = time.time() - t0
print(f"完全一致検索 200件 — 線形:{linear_time:.4f} 秒")print(f"完全一致検索 200件 — ハッシュ:{hash_time:.6f} 秒")assert hash_time < linear_time数値はマシンによって異なりますが、傾向は常に同じです。線形検索はデータが増えるにつれて遅くなります(O(n))、一方ハッシュ検索は一定の速度を保ちます(O(1))。インデックスの構築にかかる時間(O(n)で1回)は、検索を複数回行うことですぐに回収されます。検索が多いシステムほど、インデックスが有利になります。
別の道もある(マルチパス推論)
- 本物のDB(SQLite/PostgreSQL):実務では、私たちが手で作ったものをDBがやってくれます。
CREATE INDEX ... ON variants(chrom, pos)の一行で済みます。私たちが作った方法が優れている場合:インデックスが正確に何をしているのかを理解してデバッグする必要があるとき。DBをブラックボックスとしてしか使わないと、なぜ遅いのかを把握できません。 - 区間木(Interval Tree):変異が「点」ではなく「区間」(例:CNV、遺伝子領域)の場合、二分探索よりも区間木の方が優れています。トレードオフ:実装の複雑さ ↔ 区間重複クエリのパフォーマンス。
- メモリ vs ディスク:私たちのインデックスはすべてRAMにあります。データがRAMよりも大きい場合、ディスクベースのインデックス(ディスク上のB木)が必要です。本物のDBがこれをやってくれます。
核心:「正確な検索にはハッシュ、範囲検索にはソート+二分探索。」この対応は、手で作る場合でもDBを使う場合でも変わらない原理です。クエリの形状を見てインデックスを選ぶことが腕の見せ所です。
次のステップへ(下部の外部リンク)
- なぜハッシュがO(1)なのか、衝突はどのように解決するのか → ハッシュテーブル
- 二分探索の兄弟である木構造 → 二分探索木
- 先ほど作成した重複除去器と連携 → 応用編 プライマー重複除去エンジン
実際にやってみよう(独立した課題)
- 遺伝子名インデックス:
posではなく、geneを使って正確に検索するハッシュインデックスを追加してください(例:「GENE_42のすべての変異を教えて」)。 - 複数の条件: 「chr1の10000〜20000の範囲にあり、altが'G'である変異」を、範囲インデックスとフィルターを使って検索してください。
- インデックス対構築コスト: 検索を何回以上行うと、インデックス構築のコストを回収できるか、線形スキャンとの損益分岐点を実験で調べてください。
- 挑戦: 新しい変異がリアルタイムで追加されるときに、ソートされた範囲インデックスを毎回再ソートせずに、
bisect.insortを使って維持する方法を実装してください。
まとめ
私たちは、「大量のテーブルから目的のものを探す」という問題を、2種類のインデックスを使って解決しました。
- ハッシュテーブルは、正確な検索をO(1)で行えるようにしました。(点検索)
- 二分探索は、ソートされた位置での範囲検索をO(log n)で行えるようにしました。(範囲検索)
- DBインデックスは、これら2つがDBの内部で行われている処理であることを示しました。(原理の統合)
CREATE INDEXという1行のコードが魔法のように感じられたかもしれませんが、今ではその仕組みが理解できるでしょう。それは魔法ではなく、教科書で学んだハッシュと二分探索を、データに隣接して事前に作成した地図に過ぎません。
この記事は一般的な教育用例です。実際には、複数カラムインデックス、ディスクベースのストレージ、同時実行などが加わります。より詳細なバージョンは、この基本構造にあなた自身が追加するか、実績のあるDBに任せることができます。