Keyword search is fine until it isn’t. The moment a reader asks a question in natural language, LIKE '%term%' falls over. Retrieval-augmented generation (RAG) fixes that by turning your posts into vectors and matching meaning rather than characters. This tutorial builds a production-shaped RAG search endpoint on WordPress: chunking on save_post, batched embedding calls, a dedicated {$wpdb->prefix}post_embeddings table, a WP-CLI backfill command, and a capability-checked /wp-json/ai/v1/search route that scores results with cosine similarity in plain PHP.
Prerequisites
- WordPress 6.4 or later, PHP 8.1+, and WP-CLI installed.
- An OpenAI API key (or a Gemini key — the shape is nearly identical). Store it in
wp-config.phpasWPTH_OPENAI_KEY, never in the database. - A child theme or small plugin to hold the code. A plugin is safer — the logic survives theme switches.
- Comfort with
$wpdb,register_rest_route, and the save_post hook.
Why a custom table? Post meta is key-value and indexed for lookups, not for scanning thousands of 1536-dimension float arrays. A dedicated table lets you add a
post_idindex, keepchunk_textalongside the vector, and delete rows in bulk. See the $wpdb reference for the API you will use throughout.
Step 1 — Create the embeddings table
Run this on plugin activation. dbDelta is idempotent, so re-running it after a schema change is safe. The content_hash column is the key to skipping unchanged posts later.
<?php
function wpth_rag_install_table() {
global $wpdb;
$table = $wpdb->prefix . 'post_embeddings';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
chunk_index SMALLINT UNSIGNED NOT NULL DEFAULT 0,
chunk_text LONGTEXT NOT NULL,
content_hash CHAR(64) NOT NULL,
embedding LONGTEXT NOT NULL,
model VARCHAR(64) NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),
KEY content_hash (content_hash)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
register_activation_hook( __FILE__, 'wpth_rag_install_table' );
Step 2 — Chunk content and hash it
Embedding models have a token ceiling, and long posts dilute their own signal. Split content into overlapping windows of roughly 800 characters with 100 characters of overlap — the overlap preserves context across boundaries so a sentence cut in half still appears whole in one chunk.
The hash is computed from the raw post content, not the chunks. If the hash matches what is already stored, the post is unchanged and you skip the API call entirely. This single check saves the majority of your embedding spend.
<?php
function wpth_rag_chunk_content( string $content, int $size = 800, int $overlap = 100 ): array {
$text = wp_strip_all_tags( $content );
$text = preg_replace( '/\s+/', ' ', trim( $text ) );
$len = mb_strlen( $text );
$chunks = [];
for ( $i = 0; $i < $len; $i += ( $size - $overlap ) ) {
$chunks[] = mb_substr( $text, $i, $size );
if ( $i + $size >= $len ) {
break;
}
}
return $chunks;
}
Step 3 — Call the embeddings API in batches
OpenAI’s text-embedding-3-small accepts an array of inputs per request, which is dramatically faster than one call per chunk. Batch in groups of 32 — small enough to stay under the payload limit, large enough to amortise latency. Always check the HTTP status before decoding; a 429 or 401 will otherwise produce a confusing json_decode warning.
<?php
function wpth_rag_embed( array $inputs ): array|WP_Error {
$api_key = defined( 'WPTH_OPENAI_KEY' ) ? WPTH_OPENAI_KEY : '';
if ( ! $api_key ) {
return new WP_Error( 'no_key', 'Missing OpenAI API key' );
}
$response = wp_remote_post(
'https://api.openai.com/v1/embeddings',
[
'timeout' => 30,
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( [
'model' => 'text-embedding-3-small',
'input' => $inputs,
] ),
]
);
if ( is_wp_error( $response ) ) {
return $response;
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return new WP_Error( 'api_error', 'Embeddings API returned ' . wp_remote_retrieve_response_code( $response ) );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return array_map( fn( $item ) => $item['embedding'], $body['data'] );
}
Step 4 — Hook into save_post
Index on publish, not on every keystroke. The save_post hook fires on revisions and autosaves too, so guard against both. Delete existing rows for the post before inserting fresh ones — otherwise edits leave orphaned vectors that pollute your search results.
<?php
add_action( 'save_post', 'wpth_rag_index_post', 20, 3 );
function wpth_rag_index_post( $post_id, $post, $update ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( 'publish' !== $post->post_status || ! in_array( $post->post_type, [ 'post', 'page' ], true ) ) {
return;
}
$hash = hash( 'sha256', $post->post_content );
global $wpdb;
$table = $wpdb->prefix . 'post_embeddings';
$existing = $wpdb->get_var( $wpdb->prepare(
"SELECT content_hash FROM {$table} WHERE post_id = %d LIMIT 1",
$post_id
) );
if ( $existing === $hash ) {
return; // unchanged — skip the API call
}
$wpdb->delete( $table, [ 'post_id' => $post_id ], [ '%d' ] );
$chunks = wpth_rag_chunk_content( $post->post_content );
if ( empty( $chunks ) ) {
return;
}
foreach ( array_chunk( $chunks, 32 ) as $batch ) {
$vectors = wpth_rag_embed( $batch );
if ( is_wp_error( $vectors ) ) {
error_log( 'RAG embed failed: ' . $vectors->get_error_message() );
return;
}
foreach ( $batch as $i => $chunk ) {
$wpdb->insert( $table, [
'post_id' => $post_id,
'chunk_index' => $i,
'chunk_text' => $chunk,
'content_hash' => $hash,
'embedding' => wp_json_encode( $vectors[ $i ] ),
'model' => 'text-embedding-3-small',
'updated_at' => current_time( 'mysql', true ),
] );
}
}
}
Step 5 — Backfill existing posts with WP-CLI
New posts index themselves, but the archive needs a one-off pass. A WP-CLI command is the right tool: it runs without a timeout, prints a progress bar, and can be re-run safely thanks to the hash check. See the WP-CLI commands reference for the full API.
<?php
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'wpt-rag backfill', function() {
$ids = get_posts( [
'post_type' => [ 'post', 'page' ],
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
] );
$bar = \WP_CLI\Utils\make_progress_bar( 'Indexing', count( $ids ) );
foreach ( $ids as $post_id ) {
wpth_rag_index_post( $post_id, get_post( $post_id ), true );
$bar->tick();
}
$bar->finish();
WP_CLI::success( 'Backfill complete.' );
} );
}
Run it with wp wpt-rag backfill. On a 2,000-post site expect roughly 10–20 minutes depending on chunk count and API latency.
Step 6 — Expose the REST endpoint
The route lives under a namespaced path so it cannot collide with core. The permission_callback is mandatory in modern WordPress — returning current_user_can( 'read' ) restricts access to logged-in users. If you want anonymous search, return true but add a rate limit; otherwise you are paying for every bot that finds the URL. See Adding custom endpoints.
Cosine similarity is the standard metric for normalised embeddings: the dot product of two vectors divided by the product of their magnitudes. It runs comfortably in PHP for a few thousand rows; beyond that, move scoring to a vector database.
<?php
add_action( 'rest_api_init', function() {
register_rest_route( 'ai/v1', '/search', [
'methods' => 'GET',
'permission_callback' => fn() => current_user_can( 'read' ),
'args' => [
'q' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => fn( $v ) => is_string( $v ) && mb_strlen( $v ) >= 3,
],
'limit' => [ 'default' => 5, 'sanitize_callback' => 'absint' ],
],
'callback' => 'wpth_rag_search_handler',
] );
} );
function wpth_rag_cosine( array $a, array $b ): float {
$dot = $na = $nb = 0.0;
$n = min( count( $a ), count( $b ) );
for ( $i = 0; $i < $n; $i++ ) {
$dot += $a[ $i ] * $b[ $i ];
$na += $a[ $i ] * $a[ $i ];
$nb += $b[ $i ] * $b[ $i ];
}
return ( $na && $nb ) ? $dot / ( sqrt( $na ) * sqrt( $nb ) ) : 0.0;
}
function wpth_rag_search_handler( WP_REST_Request $request ) {
$query = $request->get_param( 'q' );
$limit = min( 20, (int) $request->get_param( 'limit' ) );
$vector = wpth_rag_embed( [ $query ] );
if ( is_wp_error( $vector ) ) {
return new WP_Error( 'embed_failed', $vector->get_error_message(), [ 'status' => 502 ] );
}
$q_vec = $vector[0];
global $wpdb;
$table = $wpdb->prefix . 'post_embeddings';
$rows = $wpdb->get_results( "SELECT post_id, chunk_text, embedding FROM {$table}", ARRAY_A );
$scored = [];
foreach ( $rows as $row ) {
$vec = json_decode( $row['embedding'], true );
if ( ! is_array( $vec ) ) {
continue;
}
$score = wpth_rag_cosine( $q_vec, $vec );
$pid = (int) $row['post_id'];
if ( ! isset( $scored[ $pid ] ) || $score > $scored[ $pid ]['score'] ) {
$scored[ $pid ] = [
'score' => $score,
'excerpt' => wp_trim_words( $row['chunk_text'], 40 ),
];
}
}
uasort( $scored, fn( $a, $b ) => $b['score'] <=> $a['score'] );
$top = array_slice( $scored, 0, $limit, true );
$results = [];
foreach ( $top as $pid => $data ) {
$results[] = [
'post_id' => $pid,
'title' => get_the_title( $pid ),
'url' => get_permalink( $pid ),
'score' => round( $data['score'], 4 ),
'excerpt' => $data['excerpt'],
];
}
return rest_ensure_response( [ 'query' => $query, 'results' => $results ] );
}
Test it with curl -H "Cookie: wordpress_logged_in_..." "https://example.com/wp-json/ai/v1/search?q=how+do+I+register+a+block". You should get back ranked post IDs with titles, permalinks, and excerpts — never raw HTML, which is why the handler returns wp_trim_words output rather than the full chunk.
Troubleshooting
- Empty results after backfill. Check
SELECT COUNT(*) FROM wp_post_embeddings. If it is zero, your API key is missing or the model name is wrong. Look forRAG embed failedindebug.log. - Every save re-embeds. The hash comparison is failing because
post_contentincludes block comments that change on every save. Hash a normalised version:hash( 'sha256', wp_strip_all_tags( $post->post_content ) ). - 429 rate limits during backfill. Reduce the batch size from 32 to 8, or add
sleep( 1 )between batches in the CLI command only. - Search is slow. You are loading every row into memory. Add
LIMIT 5000to the SELECT, or pre-filter bypost_typeandpost_statuswith a join againstwp_posts. - 401 on the REST route. The
permission_callbackis rejecting you. Confirm you are sending a valid nonce (X-WP-Nonce) or a logged-in cookie.
Next steps
- Swap in Gemini. The shape is identical — POST to
generativelanguage.googleapis.com/v1beta/models/text-embedding-004:batchEmbedContentsand map the response. Keep the model name in the table so you can migrate without re-embedding everything at once. - Wire it into a block. Register a small block with
register_block_typeand call the endpoint viaapiFetch. Render results withcreateElement— neverinnerHTML. - Add hybrid ranking. Blend the cosine score with a
WP_Querykeyword score for the best of both worlds. - Move to a vector store once you pass ~10,000 chunks. pgvector, Qdrant, or Pinecone all accept the same float arrays you are already generating.
You now have a working RAG pipeline: chunking, hashing, batched embedding, a backfill command, and a capability-checked REST endpoint that ranks by meaning. The next post in this series shows how to feed those results into a chat completion so readers can ask follow-up questions.
Leave a Reply