本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
staticSearch は、XHTML5 の文書群から JSON の索引を生成して、サーバなしで全文検索を提供するツールです。カナダの University of Victoria の Humanities Computing and Media Centre が開発していて、Map of Early Modern London や Winnifred Eaton Archive といった人文学系のプロジェクトで使われています。
これを 校異源氏物語テキストDB に導入できるか、実際にビルドして確かめました。同サイトは TEI/XML(Text Encoding Initiative)を正典として全 54 巻の本文を公開しており、GitHub Actions で Saxon-HE を回して HTML・PDF・EPUB を生成しています。staticSearch も Saxon-HE を使うので、ビルド基盤としては相性が良さそうに見えます。
結果を先に書くと、ファセット検索は日本語でも問題なく動きました。本文の全文検索については、そのままでは検索結果が返らず、前処理を足すと動くようになりました。以下、実測した内容を順に記します。
検証した対象と環境
校異源氏物語テキストDB の本文は、原本(池田亀鑑編、中央公論社)の 1 行が 1 つの <span> に対応する形で HTML になっています。国立国会図書館デジタルコレクションの IIIF(International Image Interoperability Framework)画像の座標と、各行の Linked Open Data の URI がこの span に紐づいています。
<span data-corresp="https://w3id.org/kouigenjimonogatari/api/items/0005-01.json"
data-canvas="https://dl.ndl.go.jp/api/iiif/3437686/canvas/22"
data-coords="0,0,3445,4706">いつれの御時にか女御更衣あまたさふらひ給けるなかにいとやむことなきゝは</span>
<br>
<span data-corresp="...0005-02.json" ...>にはあらぬかすくれて時めき給ありけりはしめより我はと思あかり給へる御方</span>
句読点も分かち書きもない古典テキストで、1 行はおよそ 33 字です。全 54 巻で 25,065 行、857,779 字ありました。
検証環境は macOS + OpenJDK 21 です。staticSearch は Apache Ant と Saxon-HE 11.7、jing、closure-compiler を同梱しているので、追加で入れたのは Java だけでした。
ファセット検索は日本語でも動きました
先に動いた側から書きます。生成済み HTML の <head> にメタ要素を差し込むだけで、日本語の名前・値のままファセットの UI が生成されました。
<meta class="staticSearch_desc" name="巻" content="01 きりつぼ" />
<meta class="staticSearch_desc" name="部" content="第一部" />
<meta class="staticSearch_num" name="巻番号" content="1" />
<meta class="staticSearch_bool" name="和歌を含む" content="true" />

生成されたファセットは次のとおりです。
| ファセット | メタ要素のクラス | UI | 要素数 |
|---|---|---|---|
| 巻 | staticSearch_desc | チェックボックス | 54 |
| 部 | staticSearch_desc | チェックボックス | 3 |
| 巻番号 | staticSearch_num | 数値レンジ | 2 |
| 和歌を含む | staticSearch_bool | セレクト | 1 |
| 人名 / 和歌 / 地名 | <context> | スコープ絞り込み | 3 |
最後の「人名 / 和歌 / 地名」は、設定ファイルに XPath(XML Path Language)を書くと、その要素の内側だけを検索対象にするチェックボックスが生成される仕組みです。
<contexts>
<context match="*[contains-token(@class,'tei-lg')]" label="和歌"/>
<context match="*[contains-token(@class,'tei-persName')]" label="人名"/>
<context match="*[contains-token(@class,'tei-placeName')]" label="地名"/>
</contexts>
TEI の <lg>(詩節)や <persName> を HTML の class に落としてあれば、本文の HTML を書き換えずに「和歌の中だけを検索する」という絞り込みが作れます。索引のルールが HTML の外側の設定ファイルに置かれる設計なので、TEI から HTML への変換 XSLT(XSL Transformations)を触らずに調整できるのは扱いやすいところでした。
なお、ファセット用メタ要素のクラス名はアンダースコア区切りの staticSearch_desc です。バージョン 1 系のドット区切り staticSearch.desc も併記されていますが、索引側の判定に使われる正規表現(xsl/json.xsl の $filterRex)はアンダースコア形式のみを見ています。最初ドット形式で書いたところ、エラーは出ないまま「Search Filters: None found!」というレポートが出るだけだったので、少し迷いました。
本文検索は結果が返りませんでした
一方、本文の検索は「女御」でも「更衣」でも「源氏」でも 0 件でした。

索引を見ると理由がわかります。staticSearch は語ごとに JSON ファイルを書き出すのですが、そのファイル名がこうなっていました。
stems/ゝあかくなれはさうしくちまてをくり給ふうちもとも人さはかしけれはひきた_rhv5gs.json
stems/ゝあかし給きのふもおとゝの君のあなたにありとみをき給てしをにはかにはひ_rhv5gs.json
33 字の「単語」です。生成された語の数は 25,708 で、行数 25,065 とほぼ同じでした。つまり原本の 1 行がまるごと 1 語として索引されています。「女御」で引いても、索引の見出しは行そのものなので一致しません。
語が分かれる位置
なぜ行単位になるのかを追ったところ、要素の種類によって扱いが分かれていました。
最小のテストケースを作って確かめた結果です。
AAA<span>SPLITME</span>BBB → aaasplitmebbb (1 トークン)
EEE<em>GGG</em>HHH → eeeggghhh (1 トークン)
CCC<br/>DDD → ccc / ddd (2 トークン)
あいう<span>えお</span>かきく → あいうえおかきく (1 トークン)
さしす<br/>せそた → さしす / せそた (2 トークン)
span や em のようなインライン要素は、索引を作る前の decorate という工程で剥がされ、前後のテキストが連結されます。<wbr>(word break opportunity)も同様に削除されます。これに対して <br> は空白 1 文字に変換されます。xsl/tokenize.xsl にも「we assume that they are word boundary marking」というコメントがあり、意図的な設計のようです。
英語であれば <br> の前後で語が分かれるのは自然な想定です。ただし写本や版本の翻刻のように、TEI の <lb/>(改行)が語の途中に現れる資料では、行末で語が分断されます。
実際にどれくらい落ちるかを数えました。「原本の各行の中で完結している出現数」と「行を連結した本文での出現数」の差です。
| 語 | 全出現 | 行内で完結 | 行をまたぐ |
|---|---|---|---|
| たてまつり | 835 | 734 | 101(12.1%) |
| なりけり | 349 | 323 | 26(7.4%) |
| いつれ | 101 | 93 | 8(7.9%) |
| 源氏 | 45 | 42 | 3(6.7%) |
語が長いほど落ちる割合が上がります。校異という、網羅性が重視される用途では扱いにくい性質かもしれません。
ワイルドカード検索と termLimit
staticSearch にはワイルドカード検索があり、*源氏* のように書くと索引の見出し語に対して部分一致します。行が丸ごと見出しになっているので、これなら引けます。
ただし js/StaticSearch.js に termLimit という上限があり、既定値は 50 です。ワイルドカードが展開した語がこの数を超えると、超過分は使われません。1 行 1 語という索引ではこれが効きます。
| クエリ | 一致した見出し語 | 実際に使われる | 使われない |
|---|---|---|---|
*心ち* | 693 | 50 | 643 |
*たてまつり* | 719 | 50 | 669 |
*女* | 766 | 50 | 716 |
*源氏* | 43 | 43 | 0 |
低頻度の語は正しく引けて、高頻度の語ほど結果が減ります。ソースには「TODO: May need to provide an error message for this」というコメントがあり、現状では画面上の表示はありません。
なお 1 文字の「女」で検索した場合は、これとは別に minWordLength の判定で検索前に除外されます。こちらは「Not searched (too common or too short)」として画面に表示されます。
<br> を取り除いた場合
「<br> が語を分けるなら、索引用の HTML では <br> を出さなければよいのでは」と考えて試したところ、ビルドが失敗しました。
BUILD FAILED
Failed to create output file .../stems/<23,940 文字>.json
区切りが一切なくなるため、1 巻がまるごと 1 語になります。索引用に組み直した巻 01 の本文は 11,240 字で、空白文字は 0 個でした。staticSearch は見出し語を URL エンコードしてファイル名にするので、ファイル名が 23,940 字になり、macOS の NAME_MAX(255 バイト)を超えて書き出せなかった、ということのようです。
行を連結するだけでは足りず、語境界を与える工程が別に要る、ということになります。
分かち書きを挟むと動きました
そこで索引用の HTML を別に生成し、(1) <br> と行の span を外して本文を連結、(2) Janome で分かち書き、(3) 単字の助詞を除くため minWordLength を 2 に設定、という前処理を入れました。staticSearch 自体は無改造で、追加で入れたのは Python の Janome だけです。

索引は 8,619 語になり、検索が返るようになりました。クエリ側は空白分割のままなので、検索語が分かち書きの出力と 1 トークンで一致したときに引ける、という条件になります。
| 語 | 正解(出現する文書数) | 前処理なし | 分かち書きあり |
|---|---|---|---|
| 女御 | 31 | 0 | 31 |
| 更衣 | 4 | 0 | 4 |
| たてまつり | 51 | 0 | 51 |
| うらみ | 44 | 0 | 43 |
| 源氏 | 20 | 0 | 17 |
| いつれ | 41 | 0 | 0 |
行の連結が効いていることは文脈表示で確認できました。巻 01 の「うらみ」は原本の 4 行目末(…うこかしうら)と 5 行目頭(み…)に分かれていますが、連結後は 1 語として索かれています。
…へ につけ て も 人 の 心 を のみ う こかし <mark>うらみ</mark> を お ふつ もり に や あり けむい と…
一方で「いつれ」は 41 文書中 0 件でした。Janome が同梱する現代語の辞書(IPADIC)では い / つれ と分かれてしまうためです。「おほとの」も おほ / との になりました。巻 01 で測ったところ、11,258 字が 7,138 トークンになり、平均トークン長は 1.58 字でした。頻出トークンは の(301) に(293) は(270) と(206) て(192) と、単独の助詞が並びます。
「源氏」が 20 文書中 17 件、「うらみ」が 44 文書中 43 件と少しずつ落ちるのも、分かち書きの結果に左右されるためです。落ちた箇所を見ると、原因は 2 通りありました。「源氏」は現代語の「源氏名」に引かれたり、給源 / 氏 のように切れ目がずれたりしていました。「うらみ」が落ちた巻 18 では、36 字の並びがひとつのトークンとして残っており、分割そのものが行われていませんでした。
漢語や主要な和語は取れていますが、どこまで引けるかは分かち書きに使う辞書に左右されます。古典テキストであれば中古和文 UniDic のような時代に合った辞書を使うのが本筋だと思われます。
また、文脈表示(KWIC: Key Word In Context)が分かち書きされた形のまま出るのも、翻刻テキストの提示としては気になるところでした。索引用のテキストと表示用のテキストを分けているので当然ではあるのですが、文脈だけ原文から引き直す仕組みが要りそうです。
staticSearch 側での位置づけ
日本語が想定されていないことは、staticSearch のリポジトリ内に明記されていました。
tei/ ディレクトリには、TEI Guidelines のドキュメントサイトに staticSearch を適用するビルド一式が入っています。その検索ページ(tei/search.html)の脚注にこう書かれています。
The staticSearch system has stemmers for both English and French, but not (yet) for other European languages. It cannot tokenize or stem CJK languages which have no spaces between words, so sadly we can't support searching in those languages. However, if you can find the content you're looking for in one of the other languages, you can use the language links to move to the corresponding page in Japanese, Chinese or Korean.
英語とフランス語のステマーはあるが、語の間に空白がない CJK 言語はトークナイズもステミングもできないため検索に対応していない、という趣旨です。
ビルド定義も同じ方針で書かれています。tei/build.xml は索引対象をヨーロッパ諸言語に限り、ja/ ko/ zh*/ を除外したうえで、索引処理のあとに別ターゲットでコピーしています。
<!-- deployEuroGuidelines -->
<exclude name="ja/**"/>
<exclude name="ko/**"/>
<exclude name="zh*/**"/>
<!-- copyOtherGuidelines:
we copy the remaining content (language that can't be tokenized/indexed)
into the output folder so that they can at least be read. -->
つまり、日本語で検索が返らないのは不具合ではなく、公開されている範囲では CJK(中国語・日本語・韓国語)は対象外という設計です。同梱の stemmers/ にも en / fr / identity(ステミングなし)/ stripDiacritics の 4 つしかありません。多言語のコンテンツでは identity を選んでステミングを無効にする運用が想定されているようです。
設定ファイルの表記としては、issue #381(トークナイズの正規表現を設定ファイルから渡せるようにする)が Release 2.1 のマイルストーンに置かれています。現時点のクエリ側は split(/\s+/) で空白分割する実装なので、日本語で検索を成立させるには、索引側とクエリ側で同じ分割規則を共有する必要があります。
なお、js/StaticSearch.js には preProcessSearchString() という上書き前提のフックが用意されているので、クエリ側の分割はプロジェクト側で差し込める余地があります。
静的サイト向け検索ツールの日本語対応
staticSearch では前処理が前提になると分かったので、同じ「サーバなしで日本語の全文検索」という条件で他のツールも調べました。公式ドキュメントとソースコードで確認した範囲です。
| ツール | 日本語の語の切り出し | 索引の読み込み方 | 備考 |
|---|---|---|---|
| Pagefind | Charabia 経由で Lindera + UniDic | チャンク分割・遅延取得 | extended 版が既定 |
| Lunr.js + lunr-languages | TinySegmenter | 全体を一括ロード | lunr 本体の最新リリースは 2.3.9(2020 年) |
| Orama | Intl.Segmenter | メモリ常駐 | 2025 年に WebAssembly から Intl.Segmenter へ変更 |
| MiniSearch | 既定では分割なし | メモリ常駐 | トークナイザは差し替え可能 |
| Fuse.js | 既定経路では分割せず部分一致 | 索引を持たず全走査 | パターン長 32 字の上限 |
| Stork | 空白・ハイフンのみ | — | 2023 年以降の開発方針が README に記載 |
| tinysearch | 空白のみ | Xor filter | README で代替として Pagefind を案内 |
Pagefind
Pagefind は Rust 製の CLI で索引を作り、ブラウザ側は WebAssembly で検索します。日本語・中国語・タイ語のセグメンテーションを標準で持っていて、npx pagefind で入るのがその extended 版です。
セグメンテーションは Meilisearch が開発する Charabia を経由していて、日本語は Lindera + UniDic、中国語は jieba-rs でした。索引時にセグメントされるのは zh / ja / th で、韓国語は対象に入っていません。
要件に近い規模で実測しました。青空文庫の夏目漱石作品からルビとタグを除いた 88.7 万字を、3,000 字ごとに 296 ページへ分割したものです。
Indexed 296 pages / Indexed 21823 words
Note: Pagefind doesn't support stemming for the language ja.
Finished in 1.356 seconds
生成物は合計 4.0 MB(索引 1.28 MB、抜粋表示用のフラグメント 2.2 MB、WebAssembly 68 KB)でした。実際の通信量を計測すると、初回検索が 4 リクエスト・103.6 KB、2 回目以降が 1 リクエスト・33.1 KB です。バージョン 1.5.0 以降はクエリ側の分割をブラウザ内蔵の Intl.Segmenter で行うので、日本語のために追加の WebAssembly を送る必要もありません。
検索品質は次のような結果でした。
"先生" -> 173 件 "東京" -> 104 件
"吾輩は猫" -> 74 件 "坊っちゃん" -> 7 件
"電車" -> 37 件 "輩は" -> 2 件
最後の「輩は」が示すとおり、語境界をまたぐ部分文字列は一致しないことがあるようです(issue #987)。ステミングも効かないので、活用形は個別に引く必要があります。異体字や送り仮名の揺れが多い資料では、検索漏れが出やすくなりそうです。
クライアント側にデータを全部載せるタイプ
Lunr.js、MiniSearch、Orama、Fuse.js は、いずれも索引(または本文そのもの)をクライアントに載せてから検索します。数十万字を超えると初回のロードが重くなることが見込まれます。これらは実測していませんが、今回の条件では優先度を下げました。
Orama は、2025 年 2 月の PR #899 で Rust と WebAssembly のビルドが Intl.Segmenter に置き換わっています。公式ドキュメントの言語別の記述には、この変更前の構成(Lindera を WebAssembly にコンパイルする方式)を前提としたものが含まれるようなので、参照する際は注意が要りそうです。
Fuse.js は既定の経路ではトークン化を行わず、クエリ文字列そのものを Bitap アルゴリズムで本文に部分一致させます。そのため空白のない日本語でも「日本」が「日本語の文書」にヒットします。ただし全件走査であることは変わらず、パターン長 32 字の上限もあります。
サーバを立てる選択肢
Meilisearch は Lindera と UniDic を既定で有効にしてビルドされていて、辞書構成としてはこの中で最も日本語に踏み込んだ設定に見えました(検索品質そのものは試していません)。以前は --features japanese が必要でしたが、現在の依存関係を追うと既定で入っています。公式ドキュメントの言語一覧には「lindera IPA dictionary」とありますが、Charabia のソースを見ると DictionaryKind::UniDic が指定されていました。
Typesense は ICU(International Components for Unicode)の BreakIterator と kakasi を使っていて、コレクションのスキーマでフィールドごとに locale: "ja" を指定します。
どちらもサーバが要るので、GitHub Pages のような静的ホスティングだけで完結はしません。フロントは静的のまま検索 API だけ別ホストに置く構成になります。
Algolia の DocSearch は、申請条件で「developer documentation and technical blogs」を対象と定めています。研究機関のアーカイブであれば、Algolia for Open Source の非営利枠(20 万レコード・月 20 万検索、ロゴ掲出が条件)の対象範囲に近そうです。
部分一致を優先する場合
Pagefind の弱点である部分文字列検索を正面から解決する方法として、SQLite の FTS5(Full-Text Search version 5)に trigram トークナイザを使う構成があります。3 文字ずつに切る方式なので言語に依存せず、仕様上は日本語でもそのまま部分一致が効くはずです(クエリは 3 文字以上という制約があります)。
sql.js-httpvfs を使うと、HTTP の Range リクエストで SQLite ファイルの必要な部分だけを取りに行けます。GitHub Pages が Range リクエストに対応していることは実測で確認できました(HEAD だと 200 が返るので、GET に Range ヘッダを付けて 206 を確認する必要があります)。
なお、この構成自体は今回試していないので、索引のサイズと初回の通信量は別途測る必要があります。
日本語圏から出ているものとしては StaticSeek があります。形態素辞書を使わず文字 bigram(2 文字の組)の転置索引を採用していて、CJK の部分一致を明示的に狙った設計です。README にも偽陽性の増加がトレードオフとして挙げられています。2024 年に公開されたプロジェクトなので、採用時は更新状況を確認しておくとよさそうです。
ブラウザ内で形態素解析と BM25(文書検索で広く使われるランキング関数)を組み合わせる方向では、DuckDB-Wasm に Lindera や Vaporetto を載せる試みもあります。いずれも README で実験的な位置づけとされていました。
まとめ
校異源氏物語テキストDB に staticSearch を導入できるか、という当初の問いに対しては、次のような整理になりました。
ファセット検索は日本語でも動作し、TEI 由来の構造を使った「和歌の中だけ検索」のような絞り込みも、本文 HTML を書き換えずに定義できました。今回調べた範囲では、XPath で本文中の要素を指定して検索スコープを切る仕組みは他のツールに見当たりませんでした。
本文の全文検索は、公開されている範囲では CJK は対象外という設計なので、そのままでは結果が返りません。前処理を足せば staticSearch 無改造でも動きますが、分かち書きの辞書・索引のサイズ・文脈表示の見え方を自分で引き受けることになります。
本文検索を標準機能で賄えるのは Pagefind でした。ただし実測したのは校異源氏物語そのものではなく、同規模の別コーパス(青空文庫の 88.7 万字)です。そこでは索引生成 1.4 秒・初回通信 150 KB 程度に収まりました。
したがって、TEI 由来の構造を活かした絞り込みを重視するなら staticSearch と前処理、本文検索の素直さを取るなら Pagefind、という選択になりそうです。後者を選ぶ場合は、部分文字列で引けないことが実務上の障害になった時点で SQLite の FTS5 trigram を検討する、という順序が現実的だと考えています。
同種の TEI プロジェクトで試す場合は、まず <lb/> をどう出力するかと、どの分かち書き辞書を使うかを決めることになります。


コメント
…