Custom Embeddings

Wiring Custom Embeddings Into the RAG Pipeline

⏱ 17 min

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.

What you'll learn in this lesson
Calling your self-hosted embedding endpoint from PHP
wp_remote_post() against the OpenAI-compatible /v1/embeddings shape Lesson 5's serving setup exposes.
Swapping one function inside Course 10's indexing pipeline
my_plugin_index_post() changes one call, storage and search are untouched.
Why a full reindex is mandatory, not optional
Vectors from two different embedding models are never comparable, ever.
Keeping a rollback path
Why you don't delete the old embeddings until the new ones are confirmed working.
Prerequisites

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.

Don't delete the old embeddings table until the new one is confirmed better

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.

Resources & further reading

← Building Custom Embeddings for Better Semantic Search Evaluating Whether the Adapted Model Actually Improved →