Generating Content

Generating Embeddings

⏱ 17 min

Text generation produces something a human reads. Embeddings produce something your code compares. An embedding is a fixed-length array of numbers representing the meaning of a piece of text, and two pieces of text with similar meaning end up with numerically similar embeddings, close together in that vector space, regardless of whether they share any of the same words. That property is what makes embeddings useful for semantic search, “find posts about this topic” rather than “find posts containing this exact phrase,” and for similarity checks like flagging near-duplicate content. This lesson generates embeddings both one at a time and in batches, and builds a small, real semantic search feature on top of them.

What you'll learn in this lesson
Generating a single embedding
AiClient::input()->generateEmbedding(), a different entry point than the wp_ai_client_prompt() you used for text generation.
Generating embeddings in batch
Embedding many pieces of content in one call instead of one request per item.
Storing embeddings on a WordPress object
Where to keep a generated vector so you can reuse it without regenerating it.
Comparing embeddings for similarity
Cosine similarity in plain PHP, no external vector database required for a small site.
Prerequisites

Lessons 1 through 3: a configured provider, and familiarity with the WP_AI_Client_Prompt_Builder chain. Embeddings live on a separate class, not on wp_ai_client_prompt(), this lesson explains why before writing any code.

Correction: embeddings do not live on wp_ai_client_prompt()

An earlier version of this lesson assumed embeddings followed the same wp_ai_client_prompt()->generate_embedding() pattern as text generation. Direct verification against the real WordPress/php-ai-client README shows that’s wrong, there is no core-integrated, snake_case embedding function. Embeddings live on the standalone package’s AiClient class instead, shown below.

Step 1: Install the standalone package

Embedding generation is exposed through the standalone wordpress/php-ai-client Composer package’s AiClient class, not through the WordPress-core wp_ai_client_prompt() function used in the previous lessons. Install it if you haven’t already:

composer require wordpress/php-ai-client

Step 2: A single embedding

// File: my-plugin/class-content-index.php
use WordPress\AiClient\AiClient;

$embedding = AiClient::input( $post->post_content )->generateEmbedding();
$values    = $embedding->getValues();

generateEmbedding() returns an embedding result object, not a bare array, you have to call ->getValues() on it to get the actual array of floating-point numbers. Its length depends on the underlying model, common sizes are in the hundreds to low thousands of dimensions. Don’t try to echo $values directly expecting readable output, it’s meant for storage and comparison, not display.

Step 3: Batch embeddings for many pieces of content at once

Embedding an entire library of posts one request at a time works, but it’s slow and makes more round trips than necessary. AiClient::input() also accepts an array of strings:

// File: my-plugin/class-content-index.php
use WordPress\AiClient\AiClient;

$contents = wp_list_pluck( $posts, 'post_content' );

$results = AiClient::input( $contents )->generateEmbeddings();

foreach ( $posts as $i => $post ) {
	$values = $results[ $i ]->getValues();
	update_post_meta( $post->ID, '_ai_embedding', wp_json_encode( $values ) );
}

generateEmbeddings() (plural) sends the whole array as a batch and returns an array of embedding result objects in the same order you supplied the inputs, each one still needs its own ->getValues() call, the plural method batches the request, it doesn’t change the shape of an individual result. This is meaningfully cheaper in request overhead than looping generateEmbedding() once per post when you’re indexing a whole site’s content at once, which is the situation reindexing jobs are usually in.

Don't assume the SDK's naming is consistent everywhere

This is the second time in this course a method turned out to live somewhere different than the text-generation pattern would suggest. Before shipping code built on any SDK method name you haven’t personally seen in the current README or source, check github.com/WordPress/php-ai-client directly, the API surface is still young and moves fast.

Step 4: Storing embeddings so you don’t regenerate them

Regenerating an embedding for unchanged content wastes an API call and money for no benefit, an embedding is deterministic enough in practice to store and reuse until the source content changes. Post meta is a reasonable place to keep it for a small to medium site:

// File: my-plugin/class-content-index.php
use WordPress\AiClient\AiClient;

add_action( 'save_post', function ( $post_id, $post ) {
	if ( wp_is_post_revision( $post_id ) ) {
		return;
	}

	$values = AiClient::input( $post->post_content )->generateEmbedding()->getValues();
	update_post_meta( $post_id, '_ai_embedding', wp_json_encode( $values ) );
}, 10, 2 );

Regenerating on save_post keeps the stored vector in sync with the post’s actual current content, so a search never compares against a stale embedding from three revisions ago.

Step 5: Comparing embeddings with cosine similarity

Once you have vectors, “how similar is this search query to this post” becomes a math problem, not another AI call. Cosine similarity, a standard measure of how closely two vectors point in the same direction, works well here and needs nothing beyond plain PHP:

// File: my-plugin/class-content-index.php
function my_plugin_cosine_similarity( array $a, array $b ): float {
	$dot   = 0.0;
	$mag_a = 0.0;
	$mag_b = 0.0;

	foreach ( $a as $i => $value ) {
		$dot   += $value * $b[ $i ];
		$mag_a += $value * $value;
		$mag_b += $b[ $i ] * $b[ $i ];
	}

	if ( 0.0 === $mag_a || 0.0 === $mag_b ) {
		return 0.0;
	}

	return $dot / ( sqrt( $mag_a ) * sqrt( $mag_b ) );
}

A minimal semantic search over indexed posts, embedding the search query once and ranking stored posts against it:

// File: my-plugin/class-content-index.php
use WordPress\AiClient\AiClient;

function my_plugin_semantic_search( string $query, int $limit = 5 ): array {
	$query_values = AiClient::input( $query )->generateEmbedding()->getValues();

	$posts  = get_posts( array( 'numberposts' => -1, 'meta_key' => '_ai_embedding' ) );
	$scored = array();

	foreach ( $posts as $post ) {
		$stored = json_decode( get_post_meta( $post->ID, '_ai_embedding', true ), true );

		if ( ! is_array( $stored ) ) {
			continue;
		}

		$scored[] = array(
			'post_id' => $post->ID,
			'score'   => my_plugin_cosine_similarity( $query_values, $stored ),
		);
	}

	usort( $scored, fn( $a, $b ) => $b['score'] <=> $a['score'] );

	return array_slice( $scored, 0, $limit );
}

For a site with a few hundred posts, scoring every stored embedding in a PHP loop on every search is entirely workable. A site with tens of thousands of posts doing this on every request would want a proper vector index instead, but that’s a scaling concern for later, not a reason to avoid embeddings on a normal site today. Course 10, Build a RAG Knowledge Base in WordPress, covers chunking and storage strategy at that scale in full depth.

Comparing embeddings from different models breaks silently

A vector generated by one provider’s embedding model isn’t guaranteed to be comparable to one generated by a different provider or a different model version, dimensions and the meaning of “close together” aren’t standardized across models. If you switch providers (Lesson 6 covers this), regenerate every stored embedding rather than mixing old and new ones in the same similarity comparison, mismatched vectors won’t error, they’ll just quietly produce meaningless scores.

Test it: generate and inspect a real embedding

wp-env run cli wp eval '
require_once ABSPATH . "wp-load.php";
$values = \WordPress\AiClient\AiClient::input( "WordPress is open source publishing software." )->generateEmbedding()->getValues();
echo "Dimensions: " . count( $values ) . "\n";
echo "First 5 values: " . implode( ", ", array_slice( $values, 0, 5 ) ) . "\n";
'

You should see a dimension count in the hundreds or low thousands, and five real floating-point numbers, not zeros or an error.

Recap

Embeddings live on the standalone package’s AiClient class, not on the core wp_ai_client_prompt() function used for text generation. AiClient::input()->generateEmbedding()->getValues() produces a single numeric vector representing a piece of text’s meaning, AiClient::input()->generateEmbeddings() produces many at once, each result still needing its own ->getValues() call. Store generated vectors as post meta and regenerate them on save_post rather than on every request, and compare vectors with cosine similarity, a small amount of plain PHP math, to build real semantic search without needing a separate vector database for a normal-sized site.

Resources & further reading

← Generating Text with wp_ai_client_prompt() Handling Errors and Building Fallbacks →