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 JSON0.72ms111件~1.0 MB
D1 (SQLite)17.1ms111件~1.1 MB
Elasticsearch83.0ms105件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が最速なのか

  1. ネットワーク往復なし — データがWorkerのメモリ上にある
  2. SQLパースなしString.includesはV8エンジンで最適化されたネイティブ操作
  3. 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内で完結する構成になりました。

参考