,

Gemini Embeddings for WordPress Semantic Search

WordPress keyword search fails the moment a visitor phrases things differently than your post titles. Here's how to build real semantic search with Gemini embeddings: a custom table, cosine similarity, and zero extra infrastructure.

WordPress search is famously literal. Search a cooking blog for “quick weeknight dinner” and WP_Query will happily return nothing, even with fifty suitable recipes, because no title contains those exact words. Semantic search fixes this by comparing meaning instead of strings, and embeddings are how meaning becomes something a database can compare.

This post builds working semantic search on a stock WordPress install: Gemini’s embedding model, one custom table, and cosine similarity in PHP. No vector database, no external search service. That ceiling is real (we’ll get to it), but for sites up to tens of thousands of posts this approach is honestly underrated.

What an embedding is, in one paragraph

An embedding model maps text to a vector, a long list of floats, such that semantically similar texts land near each other. “Quick weeknight dinner” and “30-minute pasta recipes” produce nearby vectors even though they share no words. Search then becomes geometry: embed the query, find the stored post vectors closest to it.

Getting embeddings from Gemini

The endpoint is embedContent on an embedding model (currently gemini-embedding-001, check the live model list):

function wpss_embed( string $text, string $task_type = 'RETRIEVAL_DOCUMENT' ): array|WP_Error {
    $response = wp_remote_post(
        'https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent',
        [
            'timeout' => 30,
            'headers' => [
                'Content-Type'   => 'application/json',
                'x-goog-api-key' => WPSS_GEMINI_KEY,
            ],
            'body'    => wp_json_encode( [
                'content'  => [ 'parts' => [ [ 'text' => $text ] ] ],
                'taskType' => $task_type,
            ] ),
        ]
    );

    if ( is_wp_error( $response ) ) {
        return $response;
    }
    $data = json_decode( wp_remote_retrieve_body( $response ), true );
    return $data['embedding']['values'] ?? new WP_Error( 'wpss_bad_response', 'No embedding returned.' );
}

Note taskType: use RETRIEVAL_DOCUMENT when indexing posts and RETRIEVAL_QUERY when embedding a search query. The model optimizes the geometry for the asymmetry between short queries and long documents: it’s a free relevance boost most tutorials skip.

Storing vectors

One table, vectors serialized as JSON:

global $wpdb;
$wpdb->query( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}wpss_embeddings (
    post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
    content_hash CHAR(32) NOT NULL,
    embedding LONGTEXT NOT NULL,
    updated_at DATETIME NOT NULL,
    KEY content_hash (content_hash)
) " . $wpdb->get_charset_collate() );

The content_hash column is the cost-control trick: before re-embedding on save, compare the hash of the current content. Editors save posts constantly; content that hasn’t materially changed shouldn’t cost you an API call.

Indexing on save

add_action( 'save_post', function ( int $post_id, WP_Post $post ): void {
    if ( wp_is_post_revision( $post_id ) || 'publish' !== $post->post_status ) {
        return;
    }

    $text = $post->post_title . "\n\n" . wp_strip_all_tags( $post->post_content );
    $text = mb_substr( $text, 0, 8000 ); // stay well inside input limits
    $hash = md5( $text );

    global $wpdb;
    $existing = $wpdb->get_var( $wpdb->prepare(
        "SELECT content_hash FROM {$wpdb->prefix}wpss_embeddings WHERE post_id = %d", $post_id
    ) );
    if ( $existing === $hash ) {
        return; // unchanged content, skip the API call
    }

    $vector = wpss_embed( $text, 'RETRIEVAL_DOCUMENT' );
    if ( is_wp_error( $vector ) ) {
        return; // fail quietly; the old vector (if any) still works
    }

    $wpdb->replace( $wpdb->prefix . 'wpss_embeddings', [
        'post_id'      => $post_id,
        'content_hash' => $hash,
        'embedding'    => wp_json_encode( $vector ),
        'updated_at'   => current_time( 'mysql' ),
    ] );
}, 10, 2 );

For the initial backfill of an existing site, don’t loop over save_post: write a WP-CLI command that pages through posts and embeds in batches, with a sleep() between batches to respect rate limits. (Bulk operations via WP-CLI get a dedicated post later in this series.)

Searching: cosine similarity

function wpss_search( string $query, int $limit = 10 ): array {
    $query_vec = wpss_embed( $query, 'RETRIEVAL_QUERY' );
    if ( is_wp_error( $query_vec ) ) {
        return [];
    }

    global $wpdb;
    $rows   = $wpdb->get_results( "SELECT post_id, embedding FROM {$wpdb->prefix}wpss_embeddings" );
    $scores = [];

    foreach ( $rows as $row ) {
        $vec = json_decode( $row->embedding, true );
        $scores[ $row->post_id ] = wpss_cosine( $query_vec, $vec );
    }

    arsort( $scores );
    return array_slice( $scores, 0, $limit, true ); // post_id => score
}

function wpss_cosine( array $a, array $b ): float {
    $dot = $na = $nb = 0.0;
    foreach ( $a as $i => $v ) {
        $dot += $v * $b[ $i ];
        $na  += $v * $v;
        $nb  += $b[ $i ] * $b[ $i ];
    }
    return $dot / ( sqrt( $na ) * sqrt( $nb ) ?: 1.0 );
}

Yes, this scans every vector on every search. Before you dismiss that: PHP grinds through a few thousand cosine comparisons in tens of milliseconds. Cache query embeddings in a transient (searches repeat heavily) and this is comfortably fast for the majority of WordPress sites, which have hundreds to low-thousands of posts.

Wire it into WordPress by hooking posts_pre_query for the main search, or expose a REST endpoint for an instant-search UI. A pragmatic pattern I like: hybrid search: take semantic results, take native keyword results, merge with semantic ranked first. You keep exact-match behavior for people searching literal titles while fixing the paraphrase problem.

When to graduate

Be honest about the ceiling: at tens of thousands of posts, loading all vectors per search stops being cute. The upgrade path is drop-in conceptually: same embeddings, same similarity, different storage: pgvector if you can run Postgres alongside, or a dedicated engine like Meilisearch/Qdrant. Because the embedding logic above is isolated in one function, graduation is a storage swap, not a rewrite.

Two design notes worth stealing even if you use a SaaS search product instead: hash-gate your embedding calls (cost), and always use the query/document task-type asymmetry (quality). Those two habits are most of the gap between a demo and production semantic search.

Next up: function calling, letting Gemini safely operate WP-CLI-style actions instead of just answering questions.

This post is part of the Gemini and WordPress developer guide, which lists every post in the series.

Aditya Shah Avatar

Written by

Hosting Support Manager & DevOps Engineer at WPMU DEV. I scale and secure WordPress infrastructure, and organize WordPress Bhopal and GDG Cloud Bhopal.

More about me · LinkedIn · GitHub