Cloudflare Pages上で動くNext.js製の日本語テキスト検索APIで、Elasticsearchの代替としてCloudflare D1(SQLite)とStatic JSON(インメモリ検索)を実装し、3方式の検索性能を比較しました。
背景
古典日本語テキストの全文検索APIを運用しています。Elasticsearchを外部クラスタとして利用していましたが、以下の理由で代替を検討しました。
- 外部サービスへの依存を減らしたい
- Cloudflare Pages内で完結させたい
- データ量が小さい(約1,800件)ので、全文検索エンジンは過剰かもしれない
データ規模
| 項目 | 値 |
|---|---|
| レコード数 | 1,812件 |
| テキスト総量(UTF-8) | 約2.5 MB |
| 1レコード平均 | 約1.4 KB |
各レコードは古典日本語テキスト(数行〜十数行)、ページ番号、巻名、IIIFキャンバスURLで構成されています。
3方式の概要
1. Elasticsearch(既存)
外部Elasticsearchクラスタに対して、fetch APIでwildcardクエリを実行する方式です。
{
wildcard: { 'original_text_lines.keyword': `*${query}*` }
}
ngramアナライザーでインデクシングしていますが、実際の検索ではwildcardクエリ(*query*)を使っており、ngramインデックスの恩恵を受けていませんでした。
2. Cloudflare D1(SQLite)
Cloudflare D1にデータを格納し、LIKEで部分一致検索する方式です。
SELECT id, page, original_text, vol_str, canvas
FROM texts
WHERE original_text LIKE '%検索語%'
ORDER BY page ASC
LIMIT 20 OFFSET 0
集計(ファセット)はSQLのGROUP BYで実現します。
SELECT vol_str, COUNT(*) as doc_count
FROM texts
WHERE original_text LIKE '%検索語%'
GROUP BY vol_str
ORDER BY vol_str ASC
D1のbatch()APIで検索・カウント・集計の3クエリを同時実行できます。
3. Static JSON(インメモリ検索)
全データをJSONファイルとしてWorkerにバンドルし、Array.filter + String.includesで検索する方式です。
filtered = data.filter(r =>
queries.some(q => r.original_text.includes(q))
)
集計はMapで手動カウントします。データサイズは約2.7MB(JSONファイル)で、Cloudflare Pagesのbundle上限(25MB)に余裕で収まります。
ベンチマーク結果
検索クエリ「いつれ」、5回実行の平均値です。全正規化オプション(濁音統一、歴史的仮名遣い統一など)を有効にした状態で計測しました。
| 方式 | 検索時間 | ヒット数 | ストレージ |
|---|---|---|---|
| Static JSON | 0.72ms | 111件 | ~1.0 MB |
| D1 (SQLite) | 17.1ms | 111件 | ~1.1 MB |
| Elasticsearch | 83.0ms | 105件 | 7.6 MB |
速度比較
Static JSON ████ 0.72ms (1x)
D1 ████████████████████████████████████████████ 17.1ms (24x)
ES ████████████████████████████████████████████████████████████████████████████████████████████████████████████████ 83.0ms (115x)
Static JSONがD1の約24倍、Elasticsearchの約115倍高速でした。
ストレージ比較
Static JSON ██████ 1.0 MB
D1 ██████ 1.1 MB
ES ████████████████████████████████████████████████ 7.6 MB
Elasticsearchはngramインデックス等のオーバーヘッドにより、元データの約3倍のストレージを消費しています。
ヒット数の差異
Elasticsearchのみ105件(他は111件)と差があります。これはwildcardクエリとLIKE/includesの挙動の微妙な違いによるものです。
なぜStatic JSONが最速なのか
- ネットワーク往復なし — データがWorkerのメモリ上にある
- SQLパースなし —
String.includesはV8エンジンで最適化されたネイティブ操作 - 1,800件の線形探索は十分速い — JavaScriptで数千件の文字列比較は1ms未満で完了する
D1が遅い理由
D1は「遅い」というよりESに比べれば十分速い(5倍)のですが、Static JSONと比較すると以下のオーバーヘッドがあります。
- SQLの解析・実行計画の生成
- ディスク(実際にはストレージレイヤー)からのI/O
LIKE '%query%'は全行スキャンになる(インデックスが効かない)
Elasticsearchが遅い理由
- Cloudflare EdgeからElasticsearchクラスタへのネットワーク往復
- wildcardクエリ自体のオーバーヘッド
- JSONレスポンスのパース
ただし、83msは十分実用的な速度です。データが数万件〜数十万件になれば、Elasticsearchのインデックスが効いて逆転する可能性があります。
実装のポイント
検索バックエンドの切り替え
共通インターフェースを定義し、クエリパラメータでバックエンドを切り替え可能にしました。
// /api/search?q=いつれ&backend=static
// /api/search?q=いつれ&backend=d1
// /api/search?q=いつれ&backend=elasticsearch
日本語テキスト正規化
検索クエリの正規化(濁音統一、歴史的仮名遣い変換など)はバックエンド非依存のJavaScript処理です。クエリのバリエーションを生成し、各バックエンドでOR検索を実行します。
入力: いづれ
↓ 変換
バリエーション: [いつれ, ひつれ, ゐつれ]
↓ 各バックエンドで検索
Static: queries.some(q => text.includes(q))
D1: WHERE text LIKE '%いつれ%' OR text LIKE '%ひつれ%' OR text LIKE '%ゐつれ%'
ES: bool.should: [wildcard: *いつれ*, wildcard: *ひつれ*, ...]
ベンチマークエンドポイント
/api/benchmark?q=検索語&iterations=5 で3方式の比較結果をJSON形式で取得できます。
結論
約1,800件・2.5MBのデータであれば、Elasticsearchは不要でした。 Static JSON(インメモリ検索)で十分な性能が出ます。
方式選定の目安
| データ規模 | 推奨方式 |
|---|---|
| 〜数千件 | Static JSON |
| 数千〜数万件 | D1 (SQLite) |
| 数万件〜 | Elasticsearch等の全文検索エンジン |
今回の選択
Static JSONを採用し、Elasticsearchへの外部依存を完全に除去しました。検索速度は約115倍向上し、Cloudflare Pages内で完結する構成になりました。