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.
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 ) );
}
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.
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
- PHP AI Client SDK repository, GitHub
- WP_Query reference, developer.wordpress.org
- get_post_meta() function reference, developer.wordpress.org