症状

ログに search_api_db の警告が大量に溜まっていました。

An overlong word (more than 50 characters) was encountered while indexing: %word. Since database search servers currently cannot index words of more than 50 characters, the word was truncated for indexing. If this should not be a single word, make sure the “Tokenizer” processor is enabled and configured correctly for index %index.

「50文字を超える単語があった。データベース検索サーバーは50文字を超える単語を索引できないので、索引用に切り詰めた」。警告文の後半は「これが1単語でないなら Tokenizer プロセッサを有効にして正しく設定せよ」と、対処法まで書いてくれています。

問題は「切り詰めた」の部分です。これは単なる警告ではなく、本文の51文字目以降が索引から落ちているという報告でした。検索しても本文中の語で当たらない、という実害が裏で起きていたわけです。

なぜ日本語だけで起きるか

上限は search_api_db のバックエンド実装にハードコードされた定数です。

// search_api/modules/search_api_db/src/Plugin/search_api/backend/Database.php
protected const TOKEN_LENGTH_MAX = 50;

この値はそのまま索引テーブルの word カラムの長さにも使われています。英語なら単語がスペースで区切られているので、1単語が50文字を超えることはまずありません。日本語の本文には区切り文字がないので、一文がそのまま1単語として渡ります。数百文字の段落が1トークンになり、50文字で切られる。

つまりこれは「日本語を入れた時にだけ顔を出す」タイプの罠です。

一度目の対策は効かなかった

警告文の指示通り、Tokenizer プロセッサを有効にしました。設定画面には「Simple CJK handling」というチェックボックスがあり、これが CJK(漢字・ひらがな・カタカナ)を n-gram に分割してくれるものです。有効にして再インデックスしました。

警告は消えませんでした。

設定は入っている。CJK handling も有効になっている。それでも一文が1トークンのまま切り詰められている。

真因: プロセッサは weight の昇順で走る

Search API のプロセッサには weight があり、昇順で実行されます。この時の構成はこうなっていました。

プロセッサweight
transliteration-10
tokenizer(transliteration より後)

transliteration が先に走っていました。これが何をするかというと、テキストをラテン文字へ変換します。日本語に対して何が起きるか、実際に叩いてみます。

$t = \Drupal::transliteration();
$t->transliterate('会社', 'ja');   // huishe
$t->transliterate('案件', 'ja');   // anjian
$t->transliterate('譲渡', 'ja');   // rangdu
$t->transliterate('事業', 'ja');   // shiye
$t->transliterate('日本語', 'ja'); // ribenyu

会社 が huishe。案件 が anjian。これは日本語の読み(kaisha、anken)ではなく、中国語のピンインです。しかも第2引数に ja を明示しても変わりません。

なぜ langcode に ja を渡しても効かないのか

Drupal core が同梱している変換テーブルを見ると理由が分かります。

// core/lib/Drupal/Component/Transliteration/data/x4f.php
// 「Generic transliteration data for the PhpTransliteration class」
$base[0x1A] = 'hui';   // 会 U+4F1A
// core/lib/Drupal/Component/Transliteration/data/x79.php
$base[0x3E] = 'she';   // 社 U+793E

漢字はコードポイント単位で汎用テーブルに登録されていて、その読みが中国語です。日本語の漢字と中国語の漢字は Unicode 上で同じコードポイントを共有しているので、コードポイントだけを見るテーブルでは区別できません。

そして言語別の上書きテーブルは、core には5つしかありません。

core/lib/Drupal/Component/Transliteration/data/
  da.php  de.php  eo.php  kg.php  uk.php

ja.php は存在しません。だから 'ja' を渡しても上書きするものがなく、汎用テーブル(=中国語読み)にそのまま落ちます。

ピンイン化されると CJK 分割が「黙って」効かなくなる

ここが繋がる部分です。Tokenizer の CJK 分割は、Unicode の CJK 範囲に対する正規表現で実装されています。

// search_api/src/Plugin/search_api/processor/Tokenizer.php
$text = preg_replace_callback('/[' . $this->getPregClassCjk() . ']+/u', [$this, 'expandCjk'], $text);

getPregClassCjk() が返すのは \x{3040}-\x{309F}(ひらがな)、\x{30A1}-\x{318E}(カタカナ)、\x{4E00}-\x{9FCF}(漢字)といったCJK のコードポイント範囲です。マッチした部分だけが expandCjk() に渡され、n-gram に分割されます。

transliteration が先に走ると、テキストは huisheanjian... のような ASCII の羅列になっています。CJK のコードポイントは1つも残っていない。だから preg_replace_callback は何にもマッチせず、expandCjk() は一度も呼ばれません。

エラーも警告も出ません。CJK 分割は「対象が無かった」として静かに何もしないだけです。結果、ローマ字化された長い列が1トークンとして残り、また50文字で切り詰められる。設定画面のチェックボックスは有効なままなので、設定を見ている限り原因に辿り着けません。

対策: tokenizer を transliteration より前に置く

weight を変えて、CJK 分割が transliteration より先に走るようにします。

processor_settings:
  ignorecase:
    weights: { preprocess_index: -20, preprocess_query: -20 }
    all_fields: true
  html_filter:
    weights: { preprocess_index: -15 }
    all_fields: true
  tokenizer:
    weights: { preprocess_index: -12, preprocess_query: -12 }
    all_fields: true
    overlap_cjk: 1
    minimum_word_size: 2
  transliteration:
    weights: { preprocess_index: -10, preprocess_query: -10 }
    all_fields: true

tokenizer を -12、transliteration を -10。この順序なら、まず CJK が n-gram(minimum_word_size: 2 なので2文字ずつ)へ分割され、その短いトークンに対して transliteration が走ります。

3点補足します。

preprocess_index と preprocess_query の両方に設定する。 索引側だけ直しても、検索クエリ側が別の順序で処理されると当たりません。

html_filter も入れる。 本文が full_html の場合、タグがそのまま索引されます。こちらは索引時のみ(preprocess_index)で十分です。

expandCjk() は minimum_word_size 以下の長さの語を分割しない。 実装がこうなっています。

if ($length <= $min) {
  return ' ' . $str . ' ';
}

minimum_word_size: 2 なら2文字の語はそのまま通ります。ここを大きくしすぎると短い語が n-gram にならず取りこぼすので、日本語では2が扱いやすいです。

反映手順: config を入れただけでは直らない

設定を反映しても、既存の索引は古いままです。作り直しが必要です。

drush cim -y
drush search-api:clear <index_id>
drush search-api:index <index_id>

search-api:clear を挟まずに index だけ叩くと、既に索引済みのアイテムは skip されるので何も変わりません。再インデックス後にログを見て、search_api_db の overlong 警告が新規に出ないことを確認します。古い警告が残っていると判断を誤るので、確認前に掃除しておくと分かりやすいです。

drush watchdog:delete --type=search_api_db -y

本番でも同じで、デプロイスクリプトが config を取り込んでも再インデックスはしてくれません。clear と index は明示的に実行する必要があります。ここを忘れると「設定は入っているのに検索結果が変わらない」で、もう一周悩むことになります。

まとめ

  • search_api_db の overlong 警告は「切り詰めた」=索引からの脱落報告。 単なる警告ではなく検索漏れの実害が出ている
  • 上限50文字は定数 TOKEN_LENGTH_MAX。 区切り文字のない日本語では一文が1トークンになるので簡単に超える
  • Search API のプロセッサは weight の昇順実行。 tokenizer が transliteration より後だと、CJK 分割が効かない
  • Drupal の transliteration は日本語の漢字を中国語ピンインにする。 会社 → huishe。core に ja.php の上書きテーブルが無いため、langcode に ja を渡しても変わらない
  • ローマ字化後は CJK のコードポイントが残らないので、CJK 分割は黙って何もしない。 エラーが出ないため設定画面からは原因が見えない
  • tokenizer: -12 / transliteration: -10 の順に置く。 preprocess_index と preprocess_query の両方に設定する
  • config を反映しても既存の索引は古いまま。 search-api:clear してから search-api:index する。デプロイ自動化に含まれていないことが多い

この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

サイト・アプリ・AIプロダクトM&Aのプラットフォーム RIKKA M&A