当ブログには、先日実装した自前のRAG検索があります。記事をベクトル化して、読者の自然文の質問に意味検索で答える仕組みです。作った経緯は別記事に書いたので興味のある人は下のリンクから読んでください。
このブログをスタートして4か月、記事は順調に増えてきました。しかし、読者が目的の情報にたどり着けているのかはわかりません。そして、読者が記事を探し出せるように実装したのが、このRAG検索です。この検索ではキーワードが一致しなくても、内容が当てはまる記事を一覧で返せます。
ただ、実装しただけでは読者に使われません。RAG検索はいままで右下のチャットとして浮いているだけでした。気づかない読者も多かったと思います。せっかく作った仕組みが、サイトの導線に組み込まれていませんでした。
今回は、このRAG検索をサイト本体の検索へと組み込みました。このブログで使っているのはWordPressでテーマはSWELLを使っています。読者がいつも使う検索窓から、そのまま意味検索の結果ページが表示されるようにしました。実装における判断とつまずいた点をまとめます。

従来のキーワード検索は消さない
最初に考えた設計のポイントは、検索を意味検索に一本化するか、従来のキーワード検索を残すかどうかです。
結論としては、両方を残しました。入口は一つにまとめ、キーワード検索を土台に、その上へ意味検索を重ねる形にしました。
理由は以下の3つです。
- 固有名詞はキーワード一致のほうが確実に当たる
- 意味検索が完結すると、読者が記事本文に来ない
- 読者は虫眼鏡ボタンを使った検索に慣れている
とくに当社には2つ目が重要です。当ブログの収益は記事ページで生まれます。意味検索が表示する回答で読者が満足してしまうと、肝心の記事が読まれません。よって、回答の下には必ず記事一覧を並べ、記事をクリックしてもらう設計にしました。

検索の入口をひとつにする
当ブログには今まで検索の入口が複数ありました。ヘッダーの虫眼鏡ボタン、サイドバーの検索窓、画面右下のチャットボタンです。入口が多ければ多いほど読者は迷子になります。
まず、サイドバーの検索窓を撤去して、ヘッダーの虫眼鏡ボタンに一本化しました。虫眼鏡ボタンを残したのは、全ページで位置が変わらずスマホでも見つけやすいためです。
検索窓はモーダルのまま、見た目だけ変える
SWELLのヘッダー検索は、虫眼鏡ボタンを押すと、画面が暗転して画面中央に入力欄が表示されるモーダル方式です。
この方式自体は残しました。ヘッダーに入力欄と虫眼鏡ボタンを組み合わせたコンボボタンを配置する事も考えましたが、ヘッダーに入力欄を置くと窮屈になり過ぎるためモーダル方式のままにしました。

入力欄を大きくして意味検索を伝える
モーダルが開いたときの入力欄にも手を加えました。
入力欄を左右に大きくし、読者が文章で質問を入力しやすいようにしました。入力欄の下には、意味検索の案内を1行の補足として置きました。読者に自然文でも打てることを確実に伝えたかったからです。
検索結果ページにRAGの回答を組み込む
ここからが本題です。
今回は検索結果ページを作り替え、上部にAIの回答、下に記事一覧を並べる構造にしました。
読者は一度検索したい内容を入力するだけで、両方の結果を受け取れます。意味で答えを回答するAIと、キーワードで記事を探せる一覧が検索結果ページに揃います。
RAG側のAPIの形を確認する
まずはRAGアプリのAPIを確認しました。当社のRAGは、質問をPOSTで受けつけ、回答をストリーミングで返します。
返り方には順番があります。
- 最初の1行で、参照した記事のタイトルとURLを返す
- 続けて、回答の本文を少しずつ返す
返り値は、1行ごとにJSONが並ぶ形です。最初の行が参照記事、以降の行が本文です。
{"type":"sources","sources":[{"title":"見積りを自社の過去実績で裏づける","url":"https://example.com/..."}]}
{"type":"text","text":"「見積り」というキーワードに関連して"}
{"type":"text","text":"、記事内では次の内容が説明されています。"}この「参照記事が先、本文が後」という形が、そのまま画面設計になります。回答の下に記事リンクを並べ、送客の導線にできます。
別ドメインをまたぐのでCORSを許可する
当ブログはエックスサーバー上で動くWordPressブログです。RAGアプリは別のホスティングサービス上で動いています。よってドメインが異なります。
ブラウザから別ドメインのAPIを呼ぶには、API側でCORSを許可する必要があります。これが無いと、ブラウザが通信をブロックします。今回はRAGアプリ側に、当ブログのドメインからの呼び出しを許可する設定を足しました。
許可するオリジンを決め、レスポンスのヘッダーに加えます。POSTリクエストの前に届くOPTIONSリクエストにも同様に付加します。
const CORS = {
'Access-Control-Allow-Origin': 'https://gyomusystem.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
// プリフライト(OPTIONS)に答える
export async function OPTIONS() {
return new Response(null, { status: 204, headers: CORS });
}CORSが通ったどうか確認したいときは、デベロッパーツールのコンソールからリクエストを送信できます。

子テーマを先に用意する
検索結果ページのテンプレートは、テーマフォルダに置くしかありません。追加CSSのような、テーマに依存しない置き場所はありません。
親テーマに直接ファイルを置くと、SWELLのバージョンアップ更新で消えます。そこで今回始めて子テーマを使いました。SWELLは子テーマを公式サイトにて配布しています。これをダウンロードして、インポートし、有効化します。
新しいテンプレートを増やすときは、エックスサーバーのファイルマネージャーを使用しました。swell_childフォルダを探しそこに新しいファイルを配置します。

検索結果ページのテンプレートを更新する
検索結果ページは、SWELLの search.php が対応しています。ここに回答枠を足すため、子テーマ側で search.php を用意します。
SWELL本体の search.php を土台にして、新しい検索結果ページのテンプレートである search.php を作ります。テーマの元コードをそのまま使い、AIの回答枠を一つ足すだけにします。一覧・ページ送り・サイドバー・外枠は、すべてテーマに任せます。
子テーマに置いた search.php は、以下の通りです。SWELLの一覧を出す部分の直前に、AIの回答枠を差し込んでいます。
$search_query = get_search_query();
?>
<!-- SWELL標準のタイトル表示(元コードのまま) -->
<?php if ( $search_query ) : ?>
<section class="rag-answer">
<p class="rag-answer__q">「<?php echo esc_html( $search_query ); ?>」について</p>
<div id="ragBody"></div> <!-- 回答本文が入る -->
<ul id="ragSources"></ul> <!-- 参照記事が入る -->
</section>
<?php endif; ?>
<!-- ここから下はSWELL標準の記事一覧・ページ送り(元コードのまま) -->
回答枠は空のコンテナーです。ここへJavaScriptが回答と記事リンクを流し込みます。一覧より下は、テーマの元コードで、今回は手を付けていません。
テンプレートを更新するときは、テーマの元コードを土台にするのが確実です。外枠を自前で書き起こすと、レイアウトが崩れる可能性があります。
回答はブラウザから直接呼ぶ
AIの回答を出力するコードは、サーバー側(PHP)ではなく、ブラウザ側のJavaScriptから呼ぶ形にしました。
理由はシンプルで回答がストリーミングで届くためです。サーバー側で受け切ってから表示すると、回答をリアルタイムに少しずつ出力することができません。ブラウザから直接APIを呼べば、届いた分からそのまま画面に流せます。
読み取りの中身はこうです。APIから届くデータを1行ずつ切り出して、種類ごとに振り分けます。参照記事であれば記事欄へ、本文であれば本文欄へ文字を出力します。
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 行の途中は次へ持ち越す
for (const line of lines) {
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (msg.type === 'sources') showSources(msg.sources); // 参照記事
else if (msg.type === 'text') { body += msg.text; render(); } // 本文
}
}ここでは途中で途切れた行を次の行へ持ち越すための一行が必要です。ストリームは行の途中で区切れて届くことがあるためです。この行を省くと、JSONの解析に失敗します。
このJavaScriptコードはCode Snippetsというプラグインを使って追加しました。ただし無料版はPHPのコードしか登録できません。そこで、PHPからJavaScriptを書き出す形にしました。検索結果ページのときだけ動くよう、条件で囲んでいます。
add_action('wp_footer', function () {
if ( ! is_search() ) return; // 検索結果ページ以外では出さない
?>
<script> /* 上のストリーム読み取り */ </script>
<?php
});動作を確認する
組み込みが終わったので、いくつか検索して試してました。
違うアプローチの3つの質問を用意して、意味検索の効き方を確認します。
まずは、キーワードが記事タイトルに無い自然文です。
製造業を営んでいます。ベテラン社員の見積りのやり方を、AIに引き継がせることはできますか?
「引き継がせる」という口語には、記事タイトルと一致する言葉がありません。それでも、事業承継や暗黙知を扱う記事が返りました。意味で拾えています。

次は読者の立場を起点とした漠然とした相談です。
IT担当がいない小さな会社です。まず何から手をつければいいですか?
特定のキーワードに絞りづらい問いです。情シス不在の記事や、IT管理の基本を扱う記事が返りました。読者が言葉にしづらい状態を、意味で受け止めれています。

最後に、固有名詞を含む具体的な問いです。
ExcelとGoogleスプレッドシートは、どちらに乗り換えるべきですか?
ExcelとGoogleスプレッドシートは、キーワード検索でも当たる固有名詞です。ここでは、比較と乗り換えを扱う記事が上位に返りました。固有名詞と意味の両方が効いています。

3つとも回答の下に根拠となった記事が一覧で並びます。読者は回答で概要をつかみ、記事で詳細を知ることができます。狙った導線を確立することができました。
外部APIに依存するという弱点
組み込みが終わったころ、意味検索が急に止まりました。
RAGが内部で使う埋め込みモデル(記事や質問をベクトルに変える外部API)が、認証に失敗していました。提供元はOpenAIです。ログインの画面までエラーを返しており、OpenAI側の障害でした。
この頃、OpenAIは新しいモデル群を相次いで公開していました。断定はできませんが、公開に伴う負荷や変更が、障害の背景にあった可能性はあります。ここでは推測にとどめます。
このとき、下のキーワード検索は動いていました。上のAI回答だけが止まり、読者は記事一覧から探せます。従来検索を残した判断が、ここで効きました。
同時に弱点もはっきりしました。意味検索は外部APIの稼働に依存します。提供元が止まれば、意味検索も止まります。
これは、当ブログが別記事で書いてきたクラウド依存のリスクそのものです。値上げ・停止・規制への備えを説く側が、自社の検索で同じ問題に当たりました。
対策には2つの方向性が考えられます。
- 障害時は、エラーではなく「AI検索は一時的に使えません。下の一覧からお探しください」と案内する。
- 埋め込みモデルを別の提供元へ切り替えられるようにする。
2つ目は簡単ではありません。埋め込みは提供元ごとに互換性がなく、記事のベクトルを作り直す必要があるためです。この点については今後の課題とします。
まとめ
今回、実装したことは次のとおりです。
- 検索の入口をヘッダー一つにまとめた
- 検索結果ページに、AIの回答と記事一覧を並べた
- 意味検索が止まっても、キーワード検索で探せるようにした
意味検索は便利です。ただ、答えきってしまうと記事が読まれず、外部APIが止まれば動きません。この2つを、従来のキーワード検索との共存で補いました。
作った仕組みを、実際のサイトへどう馴染ませるか。今回の記録が、同じ組み込みを試みる方の参考になれば幸いです。


コメント