Cómo acelerar el buscador de WordPress usando un índice JSON estático

Sin comentarios

23.07.2026|

Sin comentarios

Tiempo de lectura Lectura: 11 min, 15 s
Número de palabras Palabras: 2083
Número de visitas Visitas: 3
Icono de traducción

Que el buscador de WordPress es una patata no es un secreto para nadie. En lo que respecta a lo estético es relativamente fácil de solucionar, sin embargo, su funcionamiento es otra historia ya que no ofrece opción alguna de configuración, pero es que encima el rendimiento es penoso.

Cuando un sitio en WordPress acumula miles de entradas, el buscador nativo empieza a fallar. Se ralentiza hasta convertirse en algo prácticamente inútil. Al realizar una búsqueda, WordPress ejecuta una consulta LIKE %palabra% en la base de datos que escanea todo el contenido de la tabla wp_posts. Esto genera consultas SQL que pueden tardar desde varios segundos hasta una eternidad y provocar picos de memoria que terminan bloqueando el servidor.

Esto es justo lo que ya me estaba pasando. Con 6040 post publicados (solo en español) los tiempos de respuesta de las búsquedas con más resultados eran un verdadero infierno con tiempos superiores a 10 segundos o más. Y llegó el día de solucionarlo de una vez por todas.

Tras darle muchas vueltas, encontré que había al menos tres posibilidades para arreglarlo. La primera y más sencilla era tirar de algún plugin de pago como Relevanssi (su versión gratis es insuficiente) o un servicio externo como Algolia, cosa que descarté de entrada porque lo que busco es agilizar las búsquedas de forma nativa.

De las dos opciones restantes probé la a crear un índice FULLTEXT en MySQL mediante consultas MATCH() AGAINST()) y fallé miserablemente. A pesar de ajustar los parámetros del motor InnoDB y optimizar los índices, MySQL seguía atascándose al procesar miles de palabras clave complejas, devolviendo tiempos de respuesta inaceptables y saturando el servidor. Fue entonces cuando descarté por completo dejarle la carga pesada a la base de datos y opté por la tercera vía, construir un índice estático en JSON.

En este tutorial se intenta explicar cómo resolver este problema sustituyendo las consultas pesadas de MySQL por un archivo JSON estático que actúa como un índice ultrarrápido de búsqueda.

Paso 1: Detectar y eliminar consultas pesadas o redundantes

Antes de aplicar el índice JSON, es fundamental limpiar el terreno. Muchos plugins (como directorios, maquetadores o plugins de búsquedas secundarias) se enganchan al proceso de búsqueda de WordPress mediante hooks (pre_get_posts, posts_where, etc.), disparando consultas adicionales e innecesarias a la base de datos.

¿Cómo detectar estas consultas con Query Monitor?

  1. Instala y activa el plugin gratuito Query Monitor.
  2. Realiza cualquier búsqueda en tu web.
  3. Abre el menú desplegable de Query Monitor en la barra superior y selecciona Consultas de base de datos (Database Queries).
  4. Filtra por Consultas lentas o examina el listado buscando las consultas que tardan más tiempo. Ya de paso anota también las consultas duplicadas y, si puedes, soluciónalo también.
  5. Fíjate en la columna de detalle de las tablas: ahí verás exactamente qué plugin o archivo está ejecutando esa consulta.

En mi caso tuve que solucionar un puñado de consultas duplicadas del plugin Name_Directory, y algunos bloques de GenerateBlocks de la plantilla de post, pero cualquier plugin, código personalizado o elemento añadido puede estar generando consultas que lastran la respuesta.

Cómo desactivar esos hooks por código

Una vez localizado el nombre de la función o clase que dispara la consulta lenta, puedes desengancharla en tu archivo functions.php o mediante un plugin de fragmentos de código (como Code Snippets , Code de Perfmatters o similar):

* Nota. En 1 debes añadir tus hooks que generan consultas lentas.

/**
 * 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);

Nota: Para desenganchar un hook con remove_action o remove_filter, debes utilizar exactamente el mismo nombre de evento (pre_get_posts), el mismo nombre de la función que identificaste en Query Monitor y la misma prioridad con la que fue añadida (por defecto 10).

Paso 2: Generar y mantener actualizado el archivo JSON de búsqueda

Para reemplazar a MySQL, necesitamos crear un archivo estático en formato JSON que contenga los datos clave de nuestras entradas (ID, título y texto plano). Este archivo se guardará en la carpeta wp-content/uploads/ y se actualizará automáticamente cada vez que crees, edites o borres una entrada.

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));
}

Generación inicial: La primera vez que instales este snippet, puedes ejecutar temporalmente mi_sitio_reconstruir_indice_json_completo(); o simplemente guardar/actualizar cualquier entrada existente para que el archivo search-es.json se cree por primera vez. Una vez hecho esto visita su ruta wp-content/uploads/ para asegurarte de que se ha creado y conocer su peso.

Para que te hagas una idea del peso aproximado de este search-es.json, para mis 6040 entradas se generó un archivo de 3,41 Mb, un peso bastante manejable ya que hasta que no empiece a pesar más de 15 o 20 MB (el equivalente a unas 30.000 o 40.000 entradas extensas) no hay que preocuparse, porque los archivos JSON estáticos se cargan directamente en la memoria caché del servidor o de PHP (file_get_contents) de golpe, y un tamaño de unos pocos megas se lee en menos de una milésima de segundo; sin embargo, si el archivo crece desmesuradamente, cada vez que se ejecute una búsqueda PHP consumirá un pico extra de memoria RAM innecesario para volcar un JSON gigantesco, momento en el cual convendría plantearse indexar solo los títulos y extractos en lugar de todo el contenido completo, o migrar a una solución de búsqueda indexada en base de datos dedicada.

Paso 3: Función auxiliar para normalizar búsquedas (sin acentos)

Para garantizar que las búsquedas encuentren resultados independientemente de cómo escriba el usuario (con o sin tildes, mayúsculas o minúsculas), se define una función de limpieza:

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);
    }
}

Paso 4: Interceptar la consulta principal con posts_pre_query

Mediante el filtro posts_pre_query, se intercepta la consulta de búsqueda de WordPress antes de que toque la base de datos. Se lee el JSON, se encuentran los IDs que coinciden con los términos buscados y se le devuelve a WordPress únicamente esas entradas especificando la paginación correspondiente.

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);

Paso 5: Verificación de resultados

Una vez realizados los pasos anteriores, realiza una prueba de búsqueda y abre Query Monitor:

  1. Tiempo total de BD: Verás que ahora pasa de varios segundos a apenas unos milisegundos (normalmente entre 0,03s y 0,2s).
  2. Consultas SQL: La cláusula LIKE %término% habrá desaparecido por completo.
  3. Memoria RAM: El pico de uso de memoria se mantendrá excepcionalmente bajo (alrededor de 30 MB).
  4. Ordenación: Las entradas se mostrarán en estricto orden cronológico descendente (de la más reciente a la más antigua), respetando la paginación nativa de WordPress.

Estoy más que satisfecho con el resultado. No solo me he quitado un problema que arrastraba desde hace años, ahora, la respuesta es poco menos que espectacular. En una de las consultas más exigentes, como esta de 4887 resultados para: "viñeta", el buscador responde muy rápido. Para búsquedas con menos resultados se puede decir que se muestran al instante. Puedes comprobarlo buscando cualquier otra cosa.

Cómo acelerar el buscador de WordPress usando un índice JSON estático 1

Donar

Documentales sobre dibujantesTontolares, los titulares más tontos

Deja un comentario

¿Algo que decir?

Este blog se aloja en LucusHost

LucusHost, el mejor hosting

Tu WordPress puede volar

Servicio de optimización de WordPress
Quiero velocidad

Servicio de optimización

Suscripción por e-mail

Recibe gratis los artículos completos en tu correo sin publicidad en el momento que se publiquen. Se envía el contenido íntegro del feed sin herramientas externas y sin anuncios.