Building It

Voice Search Over Your Content

⏱ 16 min

A voice widget that speaks fluent, confident, wrong answers is worse than no voice widget at all, since a spoken answer carries an authority a visitor is less likely to double-check than text they can reread. This lesson connects the transcript coming out of Lessons 2 and 3 to Course 10’s actual retrieval pipeline, so a spoken question gets answered from your real site content, with the same “I don’t have information on that” honesty Course 10 built in, not a model’s best guess dressed up in a synthesized voice.

What you'll learn in this lesson
A dedicated voice search endpoint
Separate from the general chat endpoint, built specifically for a single spoken question and answer.
Feeding a transcript into Course 10's semantic search
my_plugin_semantic_search() and the grounded prompt pattern, unchanged.
Speaking the grounded answer, and its sources, back
Reusing Lesson 4's TTS pipeline for the response.
Why the refusal path matters even more here
A spoken "I don't know" is a better outcome than a spoken hallucination.
Prerequisites

Course 10’s Grounding an AI Assistant’s Answers in Retrieved Content for my_plugin_answer_from_knowledge_base(), and this course’s Lesson 4 for my_plugin_speak_text().

Step 1: Why this is a separate endpoint from the chat widget

Lesson 5’s chat widget already calls a REST endpoint that talks to wp_ai_client_prompt() directly. Voice search is a narrower job: one question, one grounded answer, sourced explicitly from your content, no multi-turn conversation state to track. Keeping it as its own endpoint keeps that distinction clear in the code, rather than overloading the general chat endpoint with a “but sometimes only answer from retrieved content” branch.

// File: inc/rest-voice-search-endpoint.php
add_action( 'rest_api_init', function () {
	register_rest_route( 'my-plugin/v1', '/voice-search', array(
		'methods'             => 'POST',
		'callback'            => 'my_plugin_handle_voice_search',
		'permission_callback' => '__return_true',
	) );
} );

Step 2: Feed the transcript straight into Course 10’s pipeline

// File: inc/rest-voice-search-endpoint.php (continued)
function my_plugin_handle_voice_search( WP_REST_Request $request ) {
	$question = sanitize_textarea_field( $request->get_param( 'question' ) );

	if ( ! $question ) {
		return new WP_Error( 'missing_question', 'No question provided.', array( 'status' => 400 ) );
	}

	$result = my_plugin_answer_from_knowledge_base( $question ); // Course 10, Lesson 7

	$audio_url = my_plugin_speak_text( $result['answer'] ); // this course, Lesson 4

	return array(
		'answer'    => $result['answer'],
		'sources'   => array_map( 'get_the_title', $result['sources'] ),
		'audio_url' => is_wp_error( $audio_url ) ? null : $audio_url,
	);
}

Nothing about my_plugin_answer_from_knowledge_base() changes. It still embeds the question, runs cosine similarity against stored chunk vectors, assembles the same three-part grounded prompt, and calls generate_text(). The only thing new here is the input arriving as a voice transcript instead of typed text, and the output getting spoken instead of only displayed.

// File: assets/js/voice-search.js
function runVoiceSearch( transcript ) {
	document.getElementById( 'voice-search-status' ).textContent = 'Searching...';

	fetch( window.siteVoiceSearch.restUrl, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			'X-WP-Nonce': window.siteVoiceSearch.nonce,
		},
		body: JSON.stringify( { question: transcript } ),
	} )
		.then( function ( response ) {
			return response.json();
		} )
		.then( function ( data ) {
			var status = document.getElementById( 'voice-search-status' );
			status.textContent = data.answer;

			if ( data.sources && data.sources.length ) {
				status.textContent += ' (Sources: ' + data.sources.join( ', ' ) + ')';
			}

			if ( data.audio_url ) {
				new Audio( data.audio_url ).play();
			}
		} );
}

This reuses Lesson 2’s SpeechRecognition setup exactly, just pointed at runVoiceSearch() instead of Course 11’s chat form, one recognition result handler, two different destinations depending on which widget is listening.

Step 4: Why the refusal path is not optional here

Course 10’s grounded prompt tells the model explicitly to say it doesn’t have information on a topic rather than guess, when the retrieved chunks don’t actually cover the question. That instruction matters everywhere RAG is used, and it matters more for voice specifically:

A spoken hallucination reads as more confident than a written one

Text a visitor can reread, question, or fact-check against the source article next to it. A spoken answer, especially through a natural-sounding voice like Piper’s, tends to be taken at face value in the moment. That makes Course 10’s refusal instruction, “say plainly that you don’t have information on that topic,” not a nice-to-have here, it’s the difference between an honest voice search feature and one that confidently tells people wrong things out loud.

Test it: a question your content covers, and one it doesn’t

wp eval '
$r1 = my_plugin_answer_from_knowledge_base( "How do I request a refund?" );
echo "Q1: " . $r1["answer"] . "\n\n";

$r2 = my_plugin_answer_from_knowledge_base( "What year did the moon landing happen?" );
echo "Q2: " . $r2["answer"] . "\n";
'

The first should reflect your real, indexed content, with real source titles behind it. The second, assuming your knowledge base has nothing about space history, should be a plain statement that the assistant doesn’t have information on that topic. Then run both through the actual voice widget with the mic, confirming the spoken audio matches that same honest behavior, not a version that quietly reverts to a confident guess once speech is involved.

Recap

Voice search here is not a new answering pipeline, it’s Course 10’s existing, grounded RAG pipeline with a transcript from Lessons 2 or 3 as its input and Lesson 4’s local TTS on its output. Keeping it a dedicated endpoint rather than folding it into the general chat widget keeps its one job, a single grounded question and answer, clear in the code. And because a spoken wrong answer is genuinely more convincing than a written one, Course 10’s refusal instruction is the one piece of this lesson worth testing the hardest before shipping it.

Resources & further reading

← Building a Voice-Driven Chatbot Front End Voice Commands That Map to MCP Abilities →