How I Optimized Better Search Queries

Complex WordPress searches can spend more time joining related tables than finding matching posts. I saw this in Better Search when a query searched post meta, taxonomies, comments, and multiple terms at once.

The changes I made in Better Search 4.4.3 reduce unnecessary database work while keeping the existing relevance model intact.

How I Optimized Better Search Queries

Replacing row-multiplying joins

The older search path joined tables such as wp_postmeta, wp_term_relationships, and wp_comments whenever those fields were enabled. That creates one result row for every matching meta value, term, or comment. Better Search then has to group or deduplicate those rows before returning posts.

For a filter that only needs a yes-or-no answer, that work is unnecessary. The query now uses correlated EXISTS and NOT EXISTS subqueries in the FULLTEXT path:

-- Previous shape
FROM wp_posts AS p
LEFT JOIN wp_postmeta AS pm ON pm.post_id = p.ID
LEFT JOIN wp_comments AS c ON c.comment_post_ID = p.ID
WHERE MATCH(p.post_title, p.post_content) AGAINST ('font' IN BOOLEAN MODE)
   OR pm.meta_value LIKE '%font%'
   OR c.comment_content LIKE '%font%'
-- Current shape
FROM wp_posts AS p
WHERE MATCH(p.post_title, p.post_content) AGAINST ('font' IN BOOLEAN MODE)
   OR EXISTS (
        SELECT 1
        FROM wp_postmeta AS pm
        WHERE pm.post_id = p.ID
          AND pm.meta_value LIKE '%font%'
   )
   OR EXISTS (
        SELECT 1
        FROM wp_comments AS c
        WHERE c.comment_post_ID = p.ID
          AND c.comment_content LIKE '%font%'
   )

EXISTS can stop after the first matching related row. It also avoids multiplying post rows, reducing grouping and duplicate work. This is why it can be faster on searches involving tables with many related rows. It doesn’t guarantee that every query will be faster. The result depends on the data, indexes, cache state, and the rest of the WordPress request.

The indexed title and content search still uses MATCH ... AGAINST. The change is limited to the supporting filters.

Fixing negative searches

Negative terms now travel through a separate path. For font -plugin, font remains in the positive FULLTEXT expression and plugin is applied as an exclusion across the enabled fields:

AND p.post_title NOT LIKE '%plugin%'
AND p.post_content NOT LIKE '%plugin%'
AND NOT EXISTS (
    SELECT 1
    FROM wp_postmeta AS pm
    WHERE pm.post_id = p.ID
      AND pm.meta_value LIKE '%plugin%'
)

This also covers taxonomy terms, comments, authors, slugs, custom tables, and multisite search. Fuzzy LIKE fallback keeps the exclusions instead of replacing the complete WHERE clause.

Negative-only searches such as -plugin no longer leave an empty FULLTEXT expression that returns no results. There is no positive term to provide relevance in that case, so the custom-table path uses a zero score, and the query falls back to the normal result ordering.

The behavior change is intentional. In 4.4.2, searches such as -plugin and font -plugin could still return posts containing the excluded term. The current code removes those false matches rather than hiding valid results.

Smaller costs outside the main query

The search query was not the only source of avoidable work.

Live search now enforces the minimum character setting on the server, limits the request to 128 characters, caches responses per query, locale, and site, and sets no_found_rows because an autocomplete response does not need a total result count.

Heatmap counts are cached for 15 minutes by default. Both cache periods can be changed with bsearch_live_search_cache_time and bsearch_heatmap_cache_time.

The Pro spelling dictionary now rebuilds in ID-based batches instead of loading every published title into memory. It builds a replacement table first and swaps it into place only when the rebuild succeeds, so the existing dictionary remains available during the rebuild. Repeated saves use INSERT IGNORE, which prevents editing the same post from inflating word frequencies between scheduled rebuilds.

Custom-table result counts also skip relevance-score calculation when no relevance threshold is active. The previous count query could calculate MATCH() again over the full result set even though the score could not affect the count.

What I tested

The comparison was wider than a single plugin search. I tested the query paths that tend to behave differently:

  • Vanilla FULLTEXT searches using title and content, such as plugin and font.
  • Searches that include post meta, taxonomy names, comments, authors, excerpts, and slugs.
  • Positive and negative combinations, such as plugin -sherlock and font -plugin.
  • Negative-only searches, such as -plugin and sherlock -plugin.
  • The Pro custom-table path, including the custom count query and searches across multiple blog_id values.
  • Multisite network search, where Better Search builds a query across the configured sites.
  • Fuzzy and LIKE fallback paths, where exclusions must not be lost when FULLTEXT returns no match.

For the performance comparison, I use GPT Luna in XHigh mode to first install 4.4.2, record a baseline, deploy the 4.4.3 candidate, and repeat the same searches. It used median timings rather than relying on one warm or cold run. Result IDs were also compared.

Query4.4.2 medianCandidate medianChangeResults
Vanilla: sherlock363 ms358 ms1.4% fasterSame
Vanilla: plugin407 ms400 ms1.7% fasterSame
Meta: plugin2,142 ms397 ms81.5% fasterSame
Meta: horrid2,352 ms438 ms81.4% fasterSame
Comments: plugin700 ms376 ms46.3% fasterSame
Comments: horrid746 ms409 ms45.2% fasterSame
Complex: sherlock plugin7,551 ms375 ms95.0% fasterSame
Complex: horrid plugin7,547 ms377 ms95.0% fasterSame
Custom tables: plugin302 ms290 ms4.0% fasterSame
Network: plugin4,002 ms622 ms84.4% fasterSame
Network: sherlock plugin5,626 ms1,014 ms82.0% fasterSame
Network: horrid plugin5,291 ms925 ms82.5% fasterSame

This shows where the optimization matters. Vanilla FULLTEXT searches were already using the indexed post fields, so they changed very little. Meta searches improved by more than 81 percent because the candidate avoids the row multiplication and grouping cost from joining wp_postmeta. Comment searches improved by roughly 45 percent for the same reason.

The largest gains appeared in complex searches that combine multiple fields. The custom-table result moved only 4 percent because that path already searches a dedicated index table. Network searches improved by more than 82 percent because the old work was repeated across the sites included in the network query.

Every benchmark returned the same result IDs. We checked the negative-search tests separately for correctness across native, custom-table, and network paths. They confirm that exclusions are preserved without changing the valid positive matches.

These are median query timings from the comparison run, not a promise that every site will see the same numbers. Results depend on table size, indexes, database version, cache state, enabled search fields, and the number of sites in a network.

The result is less unnecessary joining, safer negative search handling, lower memory use during dictionary rebuilds, and measurable improvements in some workloads. I would describe this as a query-efficiency and correctness update, not a promise that every search will be faster.

Keep on searching, but a lot faster this time!

Leave a Reply

Your email address will not be published. Required fields are marked *