Content Projects

Project: Site-Content Chatbot

⏱ 17 min

This project builds a chatbot ability grounded in your own site’s content. It embeds your published posts once, and for every question, finds the most relevant posts by similarity and hands them to the model as context, so answers come from what your site actually says rather than the model’s general training data.

What you'll learn in this lesson
How to generate and store embeddings for published content
Using AiClient::input()->generateEmbedding() and post meta as simple vector storage.
How to run a similarity search in plain PHP
Cosine similarity over stored vectors, no external vector database required.
How to combine retrieved content with a question
Building a grounded prompt for wp_ai_client_prompt() rather than an ungrounded one.
Why this pattern is called retrieval-augmented generation
And why it matters for answer accuracy.
Prerequisites

Course 5 for the AI Client SDK’s embedding support. A handful of real published posts with substantial content, embeddings are only useful once there’s something to retrieve.

Step 1: Embed your published content

Before any question can be answered, every published post needs an embedding, a numeric vector representing its meaning, stored so it can be compared later. This runs once per post, and again whenever a post is updated.

// File: site-chatbot/site-chatbot.php
use WordPress\AiClient\AiClient;

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

	$text = $post->post_title . "\n\n" . wp_strip_all_tags( $post->post_content );

	$values = AiClient::input( $text )->generateEmbedding()->getValues();

	if ( ! empty( $values ) ) {
		update_post_meta( $post_id, '_site_chatbot_embedding', wp_json_encode( $values ) );
	}
}, 20, 2 );

Embeddings live on the standalone package’s AiClient class (composer require wordpress/php-ai-client), not on the core wp_ai_client_prompt() function used later in this project for the actual answer generation. generateEmbedding() returns a result object, ->getValues() gets the actual float array out of it.

Storing the embedding as JSON in post meta is intentionally simple: no extra database table, no vector database dependency. It works well up to a few thousand posts, past that, a dedicated vector store becomes worth the added complexity, but that’s outside what this project needs to prove the pattern.

Step 2: Register the chatbot ability

// File: site-chatbot/site-chatbot.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'site-chatbot/ask',
		array(
			'label'               => __( 'Ask the site chatbot', 'site-chatbot' ),
			'description'         => __( 'Answers a question using only this site\'s published content as context, grounding responses in real posts rather than general knowledge.', 'site-chatbot' ),
			'category'            => 'content',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array( 'question' => array( 'type' => 'string' ) ),
				'required'   => array( 'question' ),
			),
			'output_schema'       => array(
				'type'       => 'object',
				'properties' => array(
					'answer'  => array( 'type' => 'string' ),
					'sources' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ) ),
				),
			),
			'permission_callback' => '__return_true', // Public Q&A, adjust if this should be members-only.
			'execute_callback'    => 'site_chatbot_answer',
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

Step 3: Similarity search, then a grounded prompt

This is where retrieval meets generation. Embed the question, compare it against every stored post embedding with cosine similarity, take the top matches, and only then call wp_ai_client_prompt(), with those matches included as context.

// File: site-chatbot/site-chatbot.php
use WordPress\AiClient\AiClient;

function site_chatbot_answer( $input ) {
	$question = sanitize_text_field( $input['question'] );

	$question_embedding = AiClient::input( $question )->generateEmbedding()->getValues();
	if ( empty( $question_embedding ) ) {
		return new WP_Error( 'embedding_failed', 'Could not process the question.' );
	}

	$posts = get_posts( array(
		'post_status'    => 'publish',
		'posts_per_page' => -1,
		'meta_key'       => '_site_chatbot_embedding',
	) );

	$scored = array();
	foreach ( $posts as $post ) {
		$stored = json_decode( get_post_meta( $post->ID, '_site_chatbot_embedding', true ), true );
		if ( ! is_array( $stored ) ) {
			continue;
		}
		$scored[] = array(
			'post_id' => $post->ID,
			'title'   => $post->post_title,
			'excerpt' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 120 ),
			'score'   => site_chatbot_cosine_similarity( $question_embedding, $stored ),
		);
	}

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

	if ( empty( $top ) ) {
		return new WP_Error( 'no_content', 'No embedded content available to search.' );
	}

	$context = '';
	foreach ( $top as $match ) {
		$context .= "Post: {$match['title']}\n{$match['excerpt']}\n\n";
	}

	$prompt = "Answer the question using only the context below. If the context doesn't contain the answer, say so plainly, don't guess.\n\nContext:\n{$context}\nQuestion: {$question}";

	$answer = wp_ai_client_prompt( $prompt )->generate_text();

	return array(
		'answer'  => $answer ?: 'No answer could be generated.',
		'sources' => wp_list_pluck( $top, 'post_id' ),
	);
}

function site_chatbot_cosine_similarity( $a, $b ) {
	$dot = $mag_a = $mag_b = 0;
	$len = min( count( $a ), count( $b ) );
	for ( $i = 0; $i < $len; $i++ ) {
		$dot   += $a[ $i ] * $b[ $i ];
		$mag_a += $a[ $i ] ** 2;
		$mag_b += $b[ $i ] ** 2;
	}
	if ( $mag_a === 0 || $mag_b === 0 ) {
		return 0;
	}
	return $dot / ( sqrt( $mag_a ) * sqrt( $mag_b ) );
}
The retrieval-augmented pattern
1
Embed once
Every published post gets an embedding stored the moment it's saved.
2
Embed the question
The same embedding call, applied to whatever the user asks.
3
Rank by similarity
Cosine similarity finds which stored posts are closest in meaning to the question.
4
Ground the prompt
Only the top matches are handed to wp_ai_client_prompt() as context, not the whole site.

This is the actual meaning of “retrieval-augmented generation”: retrieve the relevant pieces first, then generate an answer constrained to them.

Test it

Save an existing post to trigger embedding, then ask a question that its content should answer:

wp-env run cli wp post update 42 --post_status=publish
wp-env run cli wp eval '
$result = wp_get_ability( "site-chatbot/ask" )->execute( array(
	"question" => "What is an ability in WordPress?",
) );
print_r( $result );
'

Confirm sources includes the post ID you’d expect, and that the answer reflects that post’s actual content rather than a generic, ungrounded response.

Asking a question with no embedded content yet

If you run the ability before any posts have been saved (and therefore embedded) since this code was activated, $scored will be empty and every question returns the no_content error. Either resave existing posts once to trigger the save_post hook retroactively, or write a one-time WP-CLI command that loops get_posts() and embeds everything that’s missing the meta key.

Recap

This project pairs AiClient::input()->generateEmbedding() for both content and questions with a plain PHP cosine similarity function for retrieval, then hands only the top matches to wp_ai_client_prompt() for a grounded answer. Storing vectors in post meta keeps the whole thing dependency-free while proving the retrieval-augmented pattern that scales to a dedicated vector store later if your content library grows large enough to need one.

Resources & further reading

← Project: Automated Blog Post Generator Project: Meta Description and Alt-Text Generator →