Lesson 5 produced a fine-tuned embedding model, served behind an HTTP endpoint. This
lesson is deliberately the smallest of the course, because that’s the honest point of
it: the entire RAG pipeline Course 10 built, the wp_ai_chunk_embeddings table, the
dbDelta() schema, the cosine similarity search, stays exactly as it was. The only
thing that changes is which function produces the vector going into that table.
Everything downstream of “here is an array of floats” is unmodified.
Course 10’s full RAG pipeline, specifically the wp_ai_chunk_embeddings table from
“Building a Custom Table for Embedding Storage” and the search function from “Semantic
Search: Cosine Similarity in PHP.” Lesson 5’s embedding model served behind a reachable
HTTP endpoint.
Step 1: Call the self-hosted embedding endpoint from PHP
Course 10 generated vectors with AiClient::input( $chunk_text )->generateEmbedding()->getValues().
That entry point is confirmed for the SDK’s built-in providers, but registering a
custom provider specifically for the embedding capability isn’t something Course 24’s
verified material covers, only the text-generation provider pattern was confirmed
there. Rather than guess at an unconfirmed class name, call Lesson 5’s endpoint
directly, the same OpenAI-compatible shape wp_remote_post() already handles well:
// File: my-plugin/class-custom-embedding.php
function my_plugin_generate_custom_embedding( string $text ): array {
$response = wp_remote_post(
'http://localhost:8080/v1/embeddings',
array(
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode(
array(
'input' => $text,
'model' => 'site-embeddings-model',
)
),
'timeout' => 30,
)
);
if ( is_wp_error( $response ) ) {
return array();
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return $body['data'][0]['embedding'] ?? array();
}
The response shape here, {"data": [{"embedding": [...]}]}, is the same
OpenAI-compatible shape Lesson 5’s Text Embeddings Inference server produces, and it’s
also exactly the shape AiClient’s own embedding methods parse internally, so this
function is a drop-in replacement, not a different kind of return value.
Step 2: Swap one call inside Course 10’s indexing function
Course 10’s my_plugin_index_post() called AiClient::input( $chunks )->generateEmbeddings()
in a single batch. The self-hosted endpoint here takes one string per request, so the
batch call becomes a loop, and that’s the entire change:
// File: my-plugin/class-embedding-table.php
function my_plugin_index_post( int $post_id ) {
global $wpdb;
$post = get_post( $post_id );
if ( ! $post || 'publish' !== $post->post_status ) {
return;
}
$wpdb->delete( $wpdb->prefix . 'ai_chunk_embeddings', array( 'post_id' => $post_id ), array( '%d' ) );
$chunks = my_plugin_chunk_by_paragraph( $post->post_content );
if ( empty( $chunks ) ) {
return;
}
foreach ( $chunks as $i => $chunk_text ) {
$vector = my_plugin_generate_custom_embedding( $chunk_text );
if ( empty( $vector ) ) {
continue; // Skip a chunk if the embedding endpoint failed for it.
}
my_plugin_store_chunk( $post_id, $i, $chunk_text, $vector );
}
}
my_plugin_chunk_by_paragraph() and my_plugin_store_chunk(), from Course 10’s
chunking and storage lessons, are called completely unchanged. The dimensions column
Course 10’s schema already includes handles a different vector length automatically,
that’s precisely why that column was worth having from the start.
Step 3: The query side changes the same one call
Course 10’s my_plugin_semantic_search() embedded the query with the same SDK call it
used for content. Swap that one line, cosine similarity and the rest of the function are
untouched:
// File: my-plugin/class-semantic-search.php
function my_plugin_semantic_search( string $query, int $top_n = 5 ): array {
global $wpdb;
$query_vector = my_plugin_generate_custom_embedding( $query );
$rows = $wpdb->get_results(
"SELECT id, post_id, chunk_text, embedding FROM {$wpdb->prefix}ai_chunk_embeddings"
);
$scored = array();
foreach ( $rows as $row ) {
$stored_vector = json_decode( $row->embedding, true );
if ( ! is_array( $stored_vector ) || count( $stored_vector ) !== count( $query_vector ) ) {
continue;
}
$scored[] = array(
'post_id' => (int) $row->post_id,
'chunk_text' => $row->chunk_text,
'score' => my_plugin_cosine_similarity( $query_vector, $stored_vector ),
);
}
usort( $scored, fn( $a, $b ) => $b['score'] <=> $a['score'] );
return array_slice( $scored, 0, $top_n );
}
my_plugin_cosine_similarity() is Course 10’s exact function, byte for byte. This is
the whole point of this lesson: everything past “produce a vector” was already correct
and stays correct.
Step 4: Reindex everything, there’s no partial migration
Because Lesson 5 established that vectors from different embedding models occupy
different, incomparable spaces, switching the embedding function requires re-running
every post through my_plugin_index_post() again, there is no way to migrate old rows
in place:
wp-env run cli wp eval '
$post_ids = get_posts( array( "post_type" => "any", "post_status" => "publish", "posts_per_page" => -1, "fields" => "ids" ) );
foreach ( $post_ids as $post_id ) {
my_plugin_index_post( $post_id );
}
echo "Reindexed " . count( $post_ids ) . " posts\n";
'
The existing dimension-mismatch guard in my_plugin_semantic_search() from Step 3
prevents a stale row from a different model producing a meaningless score in the
meantime, but stale rows sitting alongside new ones still means incomplete coverage
until the full reindex finishes.
Test it: confirm the new vectors are actually being used
wp-env run cli wp eval '
global $wpdb;
$row = $wpdb->get_row( "SELECT dimensions FROM {$wpdb->prefix}ai_chunk_embeddings LIMIT 1" );
echo "Stored dimension count: {$row->dimensions}\n";
$results = my_plugin_semantic_search( "2FA device lost", 3 );
foreach ( $results as $r ) {
printf( "Score %.3f, post %d: %s\n", $r["score"], $r["post_id"], wp_trim_words( $r["chunk_text"], 15 ) );
}
'
The reported dimension count should match Lesson 5’s model output, BAAI/bge-base-en-v1.5
produces 768-dimensional vectors, not whatever your previous generic model produced, and
that confirms the swap actually took effect rather than silently falling back to old
rows.
Keep the old, generic-model embeddings around, in a renamed table or a backup, until Lesson 7’s evaluation confirms the custom embedding model is genuinely an improvement. A custom embedding model trained on a small or biased set of domain pairs can underperform a strong generic model on content that fell outside its training distribution, and having nothing to roll back to turns a bad training run into a production outage instead of a quiet non-event.
Recap
Wiring a custom embedding model into Course 10’s RAG pipeline changes exactly one thing:
the function that produces a vector from text. wp_remote_post() against Lesson 5’s
self-hosted, OpenAI-compatible embeddings endpoint replaces the SDK’s embedding call,
Course 10’s storage schema, indexing loop, and cosine similarity search are otherwise
untouched. Because different embedding models produce incomparable vector spaces, a full
reindex is mandatory, and the old embeddings should stay available as a rollback path
until the next lesson’s evaluation confirms the switch was actually worth it.