WordpressにAIを利用したQAボットを設置してみた
ブログの記事を題材にRAGをいろいろといじってきましたが、せっかくなので、Wordpressで運営しているブログ上に、QAボットという形で実装してみました。
バックエンドはこれまで作ってきたPython+Langchainで構築していますが、WordpressのPHPとフロントエンドのJavascriptのお作法がわからなかったので、調べながら作ってみました。この記事では、Wordpress側のつくりを中心に紹介します。
全体構成
今回のシステムの全体構成を以下の図に示します。

Wordpress上で動作させるPHPの関数exec_query()が、ブラウザのJavascriptからのクエリ要求を受信し、バックエンドのPythonで動作しているプログラムにAPIを通じてクエリ要求を転送します。PythonのバックエンドでRAGを実行します。生成AIサービスには、Google Gemini Proを利用しています。
バックエンドは、Wordpressと同じサーバ上に実装していますので、WordpressのPHP関数から直接Pythonのスクリプトを起動してもよいのですが、以前、GPTs用に作成したAPIを利用したかったので、この図のような構成にしています。
WordpressでのPHP関数の登録
ブラウザのフォームに入力したクエリ文字列をJavascript(Ajax)でWordpress側に送信しますが、このとき、Wordpressでは以下のような動作になります。
- Wordpress側では、javascriptからのリクエストを受信して処理を実行するPHP関数(ここでは
exec_query())を登録しておく(アクション名と紐づけておく) - ブラウザからのリクエスト先は
/wp-admin/admin-ajax.php - ブラウザからWordpressに送信するリクエスト中に 
action: <アクション名>を含めておく - リクエストを受信したWordpressは、あらかじめ登録されたアクション名に基づいて、
exec_query()を実行する 
ブラウザ側からWordpressに登録されているPHP関数をキックするというイメージが近そうです。
以下、Wordpressでのお作法に則って、具体的な方法を述べていきます。すべて、functions.phpに記述します。
WordpressへのJavascriptファイルの登録
まず、Wordpressにアクセスしたときにブラウザに送られるjavascriptファイルを登録するPHPスクリプトを用意します。
function enqueue_scripts() {
   // JavaScriptファイルの登録
   wp_enqueue_script(
     'qabot-script',
     get_theme_file_uri('/js/qabot.js'),
     array(''),
     filemtime(get_theme_file_path('/js/qabot.js')),
     true
   );
   // リクエスト送信先URLとnonceをjavascriptに追加
   wp_add_inline_script(
     'qabot-script',
     'const qabot_ajax_params = '.json_encode(array(
         'ajaxurl' => admin_url('admin-ajax.php'),
         'qabot_ajax_nonce' => wp_create_nonce('qabot_ajax_nonce'),
     )),
     'before'
   );
}
// wp_enqueue_scripts アクションに上記関数をフック
add_action('wp_enqueue_scripts', 'enqueue_scripts');
最初のwp_enqueue_scriptは、ブラウザに送信するjavascriptファイル/js/qabot.jsをWordpressに登録する関数です。qabot-scriptは、Wordpress内でjavascriptファイルを識別するハンドル名です。
wp_enqueue_scriptの書式は以下のとおりです。
wp_enqueue_script($handle, $src, $deps, $ver, $args);
- $handle: javascriptのハンドル名
 - $src: javascriptファイルのパス名/URL
 - $deps: このjavascriptが依存するスクリプトのハンドル名(例: jQueryを利用する場合には
array('jQuery')を指定) - $ver: スクリプトのバージョン番号
 - $args: trueの場合は</body>タグの直前にこのスクリプトを配置
 
今回、/js/qabot.js内ではjQueryを利用していませんので、$depsは空になっています。wp_enqueue_scriptについては、以下のWordpressのドキュメントをご覧ください。
次のwp_add_inline_scriptは、javascriptファイルにインラインスクリプトを追加する関数です。前述の/js/qabot.jsがブラウザに送信される前に、URLとnonceを動的に追加します。
wp_add_inline_scriptt($handle, $data, $position);
- $handle: javascriptのハンドル名
 - $data: 追加するインラインスクリプトの内容
 - $position: インラインスクリプトを追加する場所(
afterを指定するとスクリプトの後に追加され、それ以外は前に追加される) 
ここでは、qabot_ajax_paramsというオブジェクトを追加しています。
  'const qabot_ajax_params = '.json_encode(array(
    'ajaxurl' => admin_url('admin-ajax.php'),
    'qabot_ajax_nonce' => wp_create_nonce('qabot_ajax_nonce'),
  )),
ajaxurlはブラウザからWordpressへの通信先となるURLで、前述のとおり/wp-admin/admin-ajax.phpとなります。
qabot_ajax_nonceには、wp_create_nonce()関数でnonceを生成して埋め込んでいます。nonceはワンタイムのセキュリティトークンで、ブラウザからこのnonceを指定して送信してもらうことで、Wordpress側で正式なリクエストであることを確認できるようになります。
wp_add_inline_scriptについては、以下のWordpressのリファレンスも参照ください。
最後のadd_action('wp_enqueue_scripts', 'enqueue_scripts')で、このPHPスクリプト全体(正確には関数enqueue_scripts())を登録します。
この場合、あらゆるページでenqueue_scripts()が読み込まれますので、特定のページのみ読み込みたい場合には、if (is_page()) {}などで括っておきましょう。
Wordpress側の処理を実行するPHP関数を登録
Wordpress側で実施する処理を記述したPHP関数も事前に登録しておきます。
// サーバ側のPHP処理
// クエリを取得してAPIに送信
function exec_query() {
  // nonceをチェック
  check_ajax_referer('qabot_ajax_nonce', '_ajax_nonce');
  // APIにクエリ文字列を送信
  $search_text = $_POST['searchText'];
  $userIp = $_SERVER['REMOTE_ADDR'];
  $url = 'https://exmple.com/api/';	// バックエンドのAPI
  $response = wp_remote_post($url, array(
      'body' => json_encode(array('query' => $search_text)),
      'headers' => array(
        'Content-Type' => 'application/json',
        'xxx-KEY' => '<api-key>',
        'X-Forwarded-For' => $userIp
      ),
      'timeout' => 60,
  ));
  // エラーチェック
  if (is_wp_error($response)) {
      wp_send_json_error('エラーが発生しました');
  } else {
	  // レスポンス内容をブラウザに転送
      $body = wp_remote_retrieve_body($response);
      wp_send_json_success(json_decode($body));
  }
}
// サーバ側のactionを登録
add_action('wp_ajax_nopriv_exec_query', 'exec_query');
add_action('wp_ajax_exec_query', 'exec_query');
おおまかな動作としては、ブラウザ側からのリクエストに含まれるクエリ文字列(searchText)を取得して、バックエンドのPythonで動作しているサーバに送信しているだけです。
以下、いくつかポイントをかいつまんで説明します。
check_ajax_refererでは、nonceの値をチェックしています。特に難しい処理を記述しなくても、これだけでnonceの値が正しいかをチェックしてくれます。もしnonceが正しい値でなければ、処理が終了となります。
wp_remote_postでは、バックエンドのAPIにクエリを送信しています。クエリ文字列に加えて、バックエンド側に設定しているAPIキー(xxx-KEY)と、ユーザのIPアドレス(X-Forwarded-For)をヘッダに付与しています。
ユーザのIPアドレスは、バックエンド側でIPアドレス単位のレートリミットを実施するために付与しています。今回のシステムの構成上、バックエンドから見ると、送信元IPアドレスはすべてWordpressが動作しているサーバのIPアドレスになってしまいますので、ヘッダにユーザのIPアドレスを含めるようにしています。
バックエンドからの応答を受信したら、wp_remote_retrieve_bodyでボディを取り出して、wp_send_json_successでそのままブラウザに返信します。
最後に、このexec_queryをWordpressのシステムに登録します。ブラウザからのリクエストに含まれるアクション名(exec_query)とPHP関数名exec_query()を紐づけます。
add_action('wp_ajax_nopriv_exec_query', 'exec_query');
add_action('wp_ajax_exec_query', 'exec_query');
この2行は以下のような違いがあります。
- wp_ajax_nopriv_[action名]: ログインしていないユーザ用
 - wp_ajax_[action名]: ログインしているユーザ用
 
Wordpressにログインしていないユーザ用と、ログインしているユーザ用でフックが異なります。今回は、ログインの有無に無関係に同じ処理を実施したいので、両方とも登録しています。
Wordpressから配信するjavascriptの内容
ブラウザからQAボットのページを開いた時に、Wordpressからブラウザに配信するjavascriptの内容を見ていきます。
その前に、HTMLは以下のようになっています。
<div>
<label for="searchText">質問を入力してください。</label>
    <input type="text" id="search-box" name="searchText">
    <button id="search-button" type="button" class="full-width-button">送信</button>
</div>
---
<div id="answer-texts" class="answer-box answer-box-hidden"></div>
<div id="answer-references" class=" answer-box-hidden">
<span>リファレンス:</span>
</div>
/js/qabot.jsの内容は以下のとおりです。事前にwp_enqueue_scriptでWordpressに登録しておいたスクリプトの中身です。表示関係する部分は、あまり本質的なところではありませんので、一部省略しています。
const button = document.getElementById("search-button");
const searchText = document.getElementById("search-box");
const answerText = document.getElementById("answer-texts");
const answerReferences = document.getElementById("answer-references");
button.addEventListener('click', ()=> {
    // ボタンを無効化&回答中を表示
    button.disabled = true;
    button.textContent = 'AIが回答を生成中...';
    // 送信するデータ
    const data = {
        action: 'exec_query',
        searchText: searchText.value,
        _ajax_nonce: qabot_ajax_params.qabot_ajax_nonce,
    };
    // Wordpress APIに送信
    fetch(qabot_ajax_params.ajaxurl, {
        method: 'POST',
        body: new URLSearchParams(data)
    })
    .then((response) => {
        if (response.ok) {
            return response.json();
        }
        else {
            throw new Error(`Error: ${response.status} ${response.statusText}`);
        }
    })
    .then((data) => {
        const answer = data.data.answer;
        const refs = data.data.reference;
        // 回答を表示
        answerText.classList.remove('answer-box-hidden')
        answerText.textContent = answer;
        // リファレンスを表示
        answerReferences.classList.remove('answer-box-hidden')
		// <省略>
        answerReferences.appendChild(listElement);
        // ボタンを有効化
        button.disabled = false;
        button.textContent = '送信';
    })
    .catch((error) => {
        console.warn(error);
    });
});
/js/qabot.jsは、通常のAjaxでやっていることと何ら変わりはありません。フォームに入力されたクエリ(質問)をWordpress側に送信し、レスポンスとして回答を受け取って画面に表示するだけです。
ただ、Wordpressならではのポイントがいくつかありますので、その部分だけ説明します。
fetch()で送信する宛先がqabot_ajax_params.ajaxurlとなっていますが、これは前述のwp_add_inline_scriptを用いて/js/qabot.jsのスクリプトにインラインで追加したオブジェクトを参照しています。
データの中身をdataオブジェクトとして定義していますが、中身は以下のとおりです。
- action: Wordpress側で登録した関数のアクション名(
exec_query) - searchText: フォームに入力された質問内容(文字列)
 - _ajax_nonce: nonce
 
actionは、前述のadd_action('wp_ajax_nopriv_exec_query', 'exec_query');などであらかじめWordpress側で登録したPHP関数のアクション名です。Wordpressはリクエストを受信すると、このactionのアクション名を見て、実行するPHP関数を選択します。
 _ajax_nonceにはnonceを設定します。値がqabot_ajax_params.qabot_ajax_nonceとなっていますが、これも同様にwp_add_inline_scriptでスクリプト内に追加されたオブジェクトを参照しています。
バックエンドの概要
WordpressからAPIでリクエストを受けて、RAGを実行するのはバックエンド側の役割です。今回は、PythonとLangchainでRAGを実装しましたが、中身は以前Zennにあげた以下の記事のとおりです。
簡単に述べると、以下のようなRAGシステムになっています。
- 特定のカテゴリの記事を短めのチャンク(記事中の<h3>タグ単位)に分割してVectorStoreにEmbeddingsとともに保存
 - 記事中の<h2>タグ単位のドキュメントをDocstoreに保存、VectorStoreとdoc_idで紐づける
 - クエリからVectorStoreを検索、Docstoreから<h2>単位の元記事とメタデータを取得
 - クエリとともにコンテキストに含めて生成AIのAPIに問い合わせ
 
これに、Wordpressからリクエストを受け付けるAPIを付けただけですので、詳細なコードは割愛します。
上記の記事との違いをしいて言えば、VectorStoreに記事のチャンクとEmbeddingsを格納するときに、metadataとしてタイトルやURLを含めたことでしょうか。今回は、生成AIからの回答とともに、VectorStoreを検索してヒットしたドキュメントのタイトルとURLも返すようにしています。
これを利用することで、最終的に生成AIの回答だけでなく、リファレンスとなった記事のリンクも表示できるようにしています。
QAボットの実施例
ということで、実際に趣味で運営している鉄道のブログにQAボットを設置してみました。

このように、生成AIからの回答に加えて、RAGでVectorStoreから引っ張ってきたチャンクの元記事のタイトルとURLも表示させています。
ブログのようなサイトでは、生成AIの回答を表示するだけでなく、その根拠となる記事へのリンクも表示することで、ユーザーの回遊性が向上します。また、ハルシネーションにより正しくない回答をしてしまうこともあるので、「生成AIの回答はあくまで参考程度に」と注記しています。どちらかというと、キーワードベースの記事検索を少し賢くしたくらいのものでしょうね。
以下の個人ブログのページで公開しています。
まとめ
Wordpressで運営している個人ブログに、RAGをシステムをベースにしたQAボットを実装してみました。実装にあたっては、Wordpressのお作法に従ってみました。
Wordpressのお作法について調べながらの実装となりましたが、ChatGPTに聞きながらやることで、特に苦労することなく作ることができました。
WordpressでのAjaxまわりの実装については、以下の記事を参考にさせていただきました。細かいところまで説明されていますので、さらに詳しく知りたい方にはおすすめです。
Discussion