Every row in Lesson 5’s table is a chunk and its embedding, sitting there unused. This
lesson turns that into an actual search: embed the user’s question, compare it against
every stored chunk, and return the handful that are closest in meaning. This is the
retrieval half of retrieval-augmented generation, and it’s the step the next lesson
hands straight to generate_text().
Lesson 5’s ai_chunk_embeddings table, populated by my_plugin_index_post(). This
lesson reads from that table, it doesn’t write to it.
Step 1: Cosine similarity, the actual math
Cosine similarity measures the angle between two vectors, not their distance, two vectors pointing in nearly the same direction score close to 1 regardless of their magnitude, which is exactly the property that makes it work well for comparing meaning rather than raw scale:
// File: my-plugin/class-semantic-search.php
function my_plugin_cosine_similarity( array $a, array $b ): float {
$dot = 0.0;
$mag_a = 0.0;
$mag_b = 0.0;
foreach ( $a as $i => $value ) {
$dot += $value * $b[ $i ];
$mag_a += $value * $value;
$mag_b += $b[ $i ] * $b[ $i ];
}
if ( 0.0 === $mag_a || 0.0 === $mag_b ) {
return 0.0;
}
return $dot / ( sqrt( $mag_a ) * sqrt( $mag_b ) );
}
The result ranges from -1 to 1 in theory, in practice embeddings from the same model tend to cluster in a narrower positive range, so what matters is relative ranking between candidates, not any specific absolute threshold you’d hardcode ahead of time.
Step 2: Embedding the query
The search query goes through the exact same embedding call as your content, using the
same model and, critically, the same usingDimensions() setting if you used one, since
comparing vectors of different lengths or from different models produces meaningless
scores (Course 5’s SDK lesson covers this pitfall in more depth):
// File: my-plugin/class-semantic-search.php
use WordPress\AiClient\AiClient;
$query_vector = AiClient::input( $user_question )->generateEmbedding()->getValues();
Step 3: Scoring every stored chunk
With the query embedded, load every stored chunk and rank it:
// File: my-plugin/class-semantic-search.php
function my_plugin_semantic_search( string $query, int $top_n = 5 ): array {
global $wpdb;
$query_vector = AiClient::input( $query )->generateEmbedding()->getValues();
$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; // Skip rows from a different model or dimension setting.
}
$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 );
}
The dimension check before scoring matters more than it looks, silently comparing vectors of mismatched length either throws a PHP notice for the missing array offset or, worse, produces a number that looks like a valid score but means nothing.
Step 4: Reading the results
// File: my-plugin/class-semantic-search.php
$results = my_plugin_semantic_search( 'How do I request a refund?' );
foreach ( $results as $result ) {
printf(
"Score %.3f, post %d: %s\n",
$result['score'],
$result['post_id'],
wp_trim_words( $result['chunk_text'], 15 )
);
}
$results is exactly the input the next lesson needs: an ordered list of the chunks
most likely to contain the answer, ready to hand to the model as grounding context.
Test it: run a real query against indexed content
wp-env run cli wp eval '
$results = my_plugin_semantic_search( "How do I request a refund?", 3 );
foreach ( $results as $r ) {
printf( "Score %.3f, post %d: %s\n", $r["score"], $r["post_id"], wp_trim_words( $r["chunk_text"], 15 ) );
}
'
You should see three scored results with the highest score first, and the top result’s text preview should genuinely relate to the query, if you’ve indexed a post that actually discusses refunds.
Cosine similarity tells you which stored chunks are closest in meaning to the query, it says nothing about whether those chunks actually contain a complete, correct answer. A chunk can score highest simply because it’s the least-unrelated thing in your entire index, if your knowledge base genuinely doesn’t cover the topic asked about. Lesson 7’s prompt construction needs to account for this: instruct the model to say it doesn’t know rather than to force an answer out of marginally-relevant retrieved chunks.
Recap
Cosine similarity, computed as a dot product divided by the product of magnitudes, is a dependency-free way to rank stored chunk embeddings against a query embedding. Embed the query with the exact same method and settings used to embed your content, score every stored row, skip any with a mismatched dimension count, and keep the top-N by score. The result is the concrete, working retrieval half of RAG, ready to ground an actual generated answer in the next lesson.
Resources & further reading
- PHP AI Client SDK repository, GitHub
- $wpdb class reference, developer.wordpress.org