
WordPressの検索機能が使い物にならないことは、誰にとっても周知の事実です。見た目の問題に関しては比較的簡単に解決できますが、機能面に関しては話が別です。設定オプションが一切ない上に、パフォーマンスもひどく劣悪だからです。
WordPressのサイトに何千もの投稿が蓄積されると、標準の検索機能が正常に動作しなくなります。動作が著しく遅くなり、事実上使い物にならなくなってしまいます。 検索を行うと、WordPressはデータベースに対して「LIKE %キーワード%」というクエリを実行し、wp_postsテーブルの全コンテンツをスキャンします。これにより生成されるSQLクエリは、数秒から非常に長い時間かかる場合があり、メモリ使用量の急増を引き起こして、最終的にサーバーをダウンさせてしまうことがあります。
まさにこれが、私がすでに経験していたことでした。6040件の投稿(スペイン語のみ)がある中で、検索結果が多い場合の表示時間は10秒以上にも及び、まさに地獄のような状態でした。そして、この問題を一度に解決する日がやってきました。
いろいろと検討した結果、この問題を解決するには少なくとも3つの方法があることがわかりました。1つ目は最も簡単な方法で、Relevanssiのような有料プラグイン(無料版では不十分です)やAlgoliaのような外部サービスを利用することでしたが、私が求めているのはネイティブ機能で検索を高速化することであるため、この方法は最初から除外しました。
残りの2つの選択肢のうち、MATCH() AGAINST() クエリを使用して MySQL に FULLTEXT インデックスを作成するという方法を試みたが、惨憺たる結果に終わった。 InnoDBエンジンのパラメータを調整し、インデックスを最適化したにもかかわらず、MySQLは数千もの複雑なキーワードを処理する際に依然として処理が滞り、許容できないほどの応答時間を返し、サーバーを飽和させてしまいました。 そこで、データベースに重い負荷をかけることを完全に断念し、3つ目の方法であるJSONでの静的インデックスの構築を選択しました。
このチュートリアルでは、MySQLの負荷の高いクエリを、超高速な検索インデックスとして機能する静的なJSONファイルに置き換えることで、この問題を解決する方法について解説します。
ステップ1:負荷の高いクエリや冗長なクエリを特定して削除する
JSONインデックスを適用する前に、環境を整えておくことが不可欠です。多くのプラグイン(ディレクトリ、レイアウトプラグイン、セカンダリ検索プラグインなど)は、フック(pre_get_posts、posts_whereなど)を介してWordPressの検索プロセスに介入し、データベースに対して余分かつ不要なクエリを発行してしまいます。
Query Monitor を使って、こうしたクエリをどのように検出すればよいでしょうか?
- 無料プラグイン「Query Monitor」をインストールして有効化してください。
- ウェブサイト内で任意の検索を行ってください。
- 上部のバーにある「Query Monitor」のドロップダウンメニューを開き、「データベースクエリ(Database Queries)」を選択します。
- 「処理に時間がかかるクエリ」でフィルタリングするか、リストを確認して処理に時間がかかるクエリを探してください。ついでに、重複しているクエリもメモしておき、可能であればそれらも修正してください。
- テーブルの詳細欄に注目してください。そこには、そのクエリを実行しているプラグインやファイルが正確に表示されています。
私の場合、Name_Directoryプラグインによる重複したクエリをいくつか修正し、投稿テンプレートのGenerateBlocksブロックもいくつか修正する必要がありましたが、どのプラグイン、カスタムコード、追加要素であっても、レスポンスを重くするクエリを生成している可能性があります。
コードでこれらのフックを無効にする方法
処理が遅くなる原因となっている関数やクラスの名前を特定したら、functions.phpファイル内でその関数やクラスを無効にするか、コードスニペット用プラグイン(Code SnippetsやPerfmatters のCodeなど)を使用して無効にすることができます:
* 注:ブロック1には、クエリの処理に時間がかかるフックを追加してください。該当しない場合は、この部分を削除し、スニペットにはブロック2のみを追加してください。
/**
* 1. Desactivar funciones invasivas o redundantes en las búsquedas globales de WP
*/
add_action('init', function() {
// Ejemplo: Si Query Monitor muestra que un plugin de directorio se ejecuta en pre_get_posts:
// remove_action('hook_de_wordpress', 'nombre_de_la_funcion_del_plugin', prioridad);
remove_action('pre_get_posts', 'name_directory_search', 10);
remove_filter('posts_where', 'name_directory_insert_sitewide_search_results', 10);
}, 20);
/**
* 2. Bloquear la búsqueda SQL nativa tipo LIKE %palabra% en MySQL
*/
add_filter('posts_search', function($search, $wp_query) {
if (!is_admin() && is_search()) {
return ''; // Neutraliza el LIKE %palabra% en MySQL globalmente en páginas de búsqueda
}
return $search;
}, 10, 2);
注:remove_action または remove_filter を使用してフックを解除するには、Query Monitor で特定したイベント名(pre_get_posts)と関数名、および追加された際の優先度(デフォルトは 10)を正確に同じものにする必要があります。
ステップ2:検索用JSONファイルを作成し、最新の状態に保つ
MySQLの代わりとして、投稿の主要データ(ID、タイトル、プレーンテキスト)を含むJSON形式の静的ファイルを作成する必要があります。このファイルはwp-content/uploads/フォルダに保存され、投稿の作成、編集、削除を行うたびに自動的に更新されます。
PHP
/**
* Actualizar el archivo search-es.json automáticamente al guardar o borrar un post
*/
add_action('save_post', 'mi_sitio_actualizar_indice_busqueda_json', 10, 3);
add_action('deleted_post', 'mi_sitio_actualizar_indice_busqueda_json', 10, 1);
function mi_sitio_actualizar_indice_busqueda_json($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_revision($post_id)) return;
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') return;
// Opcional: Filtrar por idioma si usas un plugin multilingüe como Polylang
if (function_exists('pll_get_post_language') && pll_get_post_language($post_id) !== 'es') {
return;
}
mi_sitio_reconstruir_indice_json_completo();
}
/**
* Función que recorre todas las entradas publicadas y crea el JSON
*/
function mi_sitio_reconstruir_indice_json_completo() {
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'ASC',
'fields' => 'ids', // Solo IDs para optimizar memoria
);
if (function_exists('pll_current_language')) {
$args['lang'] = 'es';
}
$all_post_ids = get_posts($args);
$indice = array();
foreach ($all_post_ids as $id) {
$titulo = get_the_title($id);
$contenido = get_post_field('post_content', $id);
$indice[] = array(
'i' => (int)$id,
't' => (string)$titulo,
'k' => (string)wp_strip_all_tags($contenido),
);
}
$upload_dir = wp_upload_dir();
$json_path = $upload_dir['basedir'] . '/search-es.json';
file_put_contents($json_path, json_encode($indice, JSON_UNESCAPED_UNICODE));
}
初期設定:このスニペットを初めてインストールした際は、一時的にmy_site_rebuild_full_json_index();を実行するか、既存の投稿を保存または更新するだけで、search-es.json ファイルが初めて生成されます。 それが完了したら、wp-content/uploads/ディレクトリにアクセスし、ファイルが正常に作成されているか、またそのファイルサイズを確認してください。
このsearch-es.jsonのおおよそのサイズ感を把握していただくために例を挙げると、私の6040件のエントリでは3.41 Mbのファイルが生成されました。15~20 MB(約30,000~40,000件の長いエントリに相当)を超えるまでは心配する必要はありません。静的なJSONファイルはサーバーのキャッシュやPHP(file_get_contents)に一括で読み込まれるため、数メガバイト程度のサイズであれば1000分の1秒未満で読み込まれます。 しかし、ファイルが過度に肥大化した場合、 検索を実行するたびに、PHPは巨大なJSONをダンプするために不要なRAMのピーク消費を余儀なくされます。その場合は、コンテンツ全体ではなくタイトルと抜粋のみをインデックス化するか、専用のデータベースインデックス検索ソリューションへの移行を検討すべきでしょう。
ステップ3:検索を正規化するための補助機能(アクセントなし)
ユーザーがどのように入力しても(アクセント記号の有無や大文字・小文字の区別にかかわらず)、検索で結果が確実に得られるようにするため、クリーニング関数を定義します:
PHP
/**
* Función auxiliar para eliminar acentos y pasar a minúsculas
*/
if (!function_exists('mi_sitio_limpiar_acentos')) {
function mi_sitio_limpiar_acentos($cadena) {
$cadena = mb_strtolower($cadena, 'UTF-8');
$string = array(
'á'=>'a', 'à'=>'a', 'ä'=>'a', 'â'=>'a', 'ª'=>'a', 'Á'=>'a', 'À'=>'a', 'Ä'=>'a', 'Â'=>'a',
'é'=>'e', 'è'=>'e', 'ë'=>'e', 'ê'=>'e', 'É'=>'e', 'È'=>'e', 'Ë'=>'e', 'Ê'=>'e',
'í'=>'i', 'ì'=>'i', 'ï'=>'i', 'î'=>'i', 'Í'=>'i', 'Ì'=>'i', 'Ï'=>'i', 'Î'=>'i',
'ó'=>'o', 'ò'=>'o', 'ö'=>'o', 'ô'=>'o', 'Ó'=>'o', 'Ò'=>'o', 'Ö'=>'o', 'Ô'=>'o',
'ú'=>'u', 'ù'=>'u', 'ü'=>'u', 'û'=>'u', 'Ú'=>'u', 'Ù'=>'u', 'Ü'=>'u', 'Û'=>'u',
'ñ'=>'n', 'Ñ'=>'n'
);
return strtr($cadena, $string);
}
}
ステップ4:posts_pre_queryを使用してメインのクエリをインターセプトする
`posts_pre_query` フィルターを使用することで、WordPress の検索クエリがデータベースに到達する前にこれをインターセプトします。JSON を読み取り、検索語句と一致する ID を特定し、対応するページネーションを指定して、それらの投稿のみを WordPress に返します。
PHP
/**
* Interceptación de la búsqueda nativa leyendo el archivo JSON
*/
add_filter('posts_pre_query', function($posts, $wp_query) {
if (!is_admin() && $wp_query->is_search() && $wp_query->is_main_query()) {
if (function_exists('pll_current_language')) {
if (pll_current_language() !== 'es') return $posts;
}
$s = $wp_query->get('s');
if (empty($s)) return array();
$upload_dir = wp_upload_dir();
$json_path = $upload_dir['basedir'] . '/search-es.json';
if (!file_exists($json_path)) return $posts;
$indice = json_decode(file_get_contents($json_path), true);
if (!is_array($indice)) return $posts;
$s_clean = mi_sitio_limpiar_acentos(wp_strip_all_tags($s));
$palabras_buscadas = array_filter(explode(' ', $s_clean));
if (empty($palabras_buscadas)) return array();
$matched_ids = array();
// Recorrer el índice acumulando coincidencias
foreach ($indice as $item) {
$titulo_clean = mi_sitio_limpiar_acentos($item['t']);
$texto_clean = mi_sitio_limpiar_acentos($item['k']);
$coinciden = true;
foreach ($palabras_buscadas as $palabra) {
if (mb_strlen($palabra, 'UTF-8') < 2) continue; // Ignorar palabras de 1 letra
if (mb_strpos($titulo_clean, $palabra) === false && mb_strpos($texto_clean, $palabra) === false) {
$coinciden = false;
break;
}
}
if ($coinciden) {
$matched_ids[] = (int)$item['i'];
}
}
// Si no hay resultados, devolver array vacío
if (empty($matched_ids)) {
$wp_query->found_posts = 0;
$wp_query->max_num_pages = 0;
return array();
}
// Paginación
$posts_per_page = (int)get_option('posts_per_page', 10);
$paged = max(1, (int)$wp_query->get('paged'));
$total_encontrados = count($matched_ids);
$wp_query->found_posts = $total_encontrados;
$wp_query->max_num_pages = ceil($total_encontrados / $posts_per_page);
// Recuperar solo los objetos de los posts encontrados, ordenados por fecha
return get_posts(array(
'post__in' => $matched_ids,
'orderby' => 'date',
'order' => 'DESC',
'posts_per_page' => $posts_per_page,
'paged' => $paged,
'post_type' => 'post',
'no_found_rows' => true,
'cache_results' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'suppress_filters' => true,
));
}
return $posts;
}, 10, 2);
ステップ5:結果の確認
上記の手順を完了したら、検索テストを実行し、Query Monitor を開きます:
- BDの合計時間:これまで数秒かかっていたものが、わずか数ミリ秒(通常は0.03秒から0.2秒)に短縮されていることがわかります。
- SQLクエリ: 「LIKE %検索語%」という句は完全に廃止されます。
- RAM:メモリ使用量のピークは極めて低い水準(約30 MB)に抑えられます。
- 並べ替え:投稿は、WordPressのデフォルトのページネーションに従い、厳密に新しい順(最新から古い順)に表示されます。
この結果には大満足です。長年抱えていた問題が解決しただけでなく、現在の検索結果はまさに素晴らしいの一言です。「viñeta」という検索語で4887件もの検索結果が出るような、最も負荷の高い検索クエリの一つでも、検索エンジンは非常に高速に反応します。検索結果が少ない場合は、瞬時に表示されると言っても過言ではありません。他のキーワードで検索して、ぜひその速さを確認してみてください。







