AIを活用して“探せる”サイトへ ─ PowerCMS X × Elasticsearchで自然文検索を実現する

公開

PowerCMS XとElasticsearchを連携できるようにすると共に、OpenAI Vector Embeddingsを活用することで、自然文で検索したり手作業で同義語を登録しなくても"意味で探せる"検索体験を実現しました。ユーザーが「PowerCMS Xのテンプレートの書き方を知りたい」といった自然文で検索しても、最適なページを自動で見つけられるようになります。

PowerCMS X公式サイトのデータを登録済の下記デモサイトにて検索をお試し頂けます。

背景:従来のサイト内検索の課題

これまでの全文検索は「文字の一致」が中心でした。たとえば「採用実績」と検索(PowerCMS Xの採用実績を検索)しても「導入事例」はヒットしません。同義語を増やすには検索機能やCMSの機能を利用して同義語を編集し続ける必要がありました。さらに、ユーザーの中には「〜を知りたい」「〜のやり方を教えて」など会話のような自然文で検索する場合もあり、単純な文字一致では拾えないケースも見受けられました。

解決策:Elasticsearch × OpenAI Vector Embeddingsによる意味検索

記事本文をOpenAIのtext-embedding-3-smallモデルでベクトル化し、Elasticsearchに備わったベクトルデータを保存するdense_vector型フィールドに保存します。「ベクトル検索とは?機械学習で向上する検索 | Elastic」等に詳しい説明があるのですが、ベクトルを利用すると意味とコンテキストを数値表現に変換し、埋め込み空間における相互の距離で類似性が分かるようになります。

"embedding": {
  "type": "dense_vector",
  "dims": 1536,
  "index": true,
  "similarity": "cosine"
}
Elasticsearchのマッピングで、1536次元のベクトルを保存する例

検索時もOpenAIのtext-embedding-3-smallモデルで検索クエリをベクトル化し、「意味の近さ」をコサイン類似度でスコア化して検索を実行します。

$response = $openai->embeddings()->create([
    'model' => 'text-embedding-3-small',
    'input' => $queryText,
]);
$embedding = $response['data'][0]['embedding'];
検索クエリのベクトルとドキュメントのベクトルの類似度を計算する例
ポイント
「導入実績」と「導入事例」のような言い回しの違いも、AIが自動で理解します。手作業で同義語を登録しなくても、意味の近いページを上位に表示できるようになります。

実装のポイント

自然文の前処理:MeCabで主題語を抽出

「テンプレートの書き方を知りたい」のような自然文から余分な表現を除去し、形態素解析で主題語にまとめることで、Embeddingの精度を高めました。Embeddingは文章全体を理解できますが、ノイズの多い日本語自然文では精度が落ちることがあります。そのため、主題語を抽出して意図がブレないクエリを作ります。Claude Codeに実装してもらいました。

public function normalizeForEmbedding(string $text): string
{
    // 敬語・依頼語の削除
    $patterns = [
        '/(を知りたい|について|とは|できますか|お願い|教えて|欲しい)/u',
        '/(してください|していただけ|しても良い|しても大丈夫)/u',
        '/(したい|方法|やり方|手順)/u',
    ];
    foreach ($patterns as $pattern) {
        $text = preg_replace($pattern, '', $text);
    }
    $text = trim($text);

    // MeCabによる形態素解析
    $cmd = sprintf(
        'echo %s | mecab --node-format="%%m\t%%f[6]\t%%f[0]\n" --eos-format=""',
        escapeshellarg($text)
    );
    exec($cmd, $output);

    $words = [];

    foreach ($output as $line) {
        [, $base, $pos] = array_pad(explode("\t", $line), 3, '');

        // より厳密な品詞フィルタリング
        if (
            !$base || $base === '*' ||
            preg_match('/^(助詞|助動詞|記号|接続詞|連体詞|感動詞|接頭詞)$/u', $pos) ||
            in_array($base, ['です', 'ます', 'ください', '下さい', 'おる', 'おります', 'くださいませ', 'こと', 'もの', 'ため', 'よう'])
        ) {
            continue;
        }

        // 1文字の名詞は除外(「の」「が」など)
        if (mb_strlen($base) === 1 && $pos === '名詞') {
            continue;
        }

        $words[] = $base;
    }

    $words = array_unique($words);
    return implode(' ', $words);
}
// 「テンプレートの書き方を知りたい」→「テンプレート 書く」
自然文の前処理例:不要表現を削除し、MeCabで主題語を抽出

BM25を併用したハイブリッド検索にする

ベクトル検索のみだと少し意図が違うデータが入ってくるように思いました。ChatGPTと議論をし、文書の重要度を評価するための強力な手法である「BM25(関連性を決定するランキングアルゴリズム)」も取り入れました。ベクトル検索は「意味の近さ」に強い一方、意図と少しずれたページも拾いがちです。一方、BM25は「検索語に近い文字列」を評価するのが得意です。 BM25とベクトル検索を組み合わせたハイブリッド検索にすることで「意図(意味)」と「文字の一致」の両方を満たす結果が得られ、より精度の高い検索結果を得ることができる感触が得られました。

// スコアの重み付け (合計が意味を持つように調整)
$bm25_weight = 0.25;    // BM25(キーワードマッチ)の重み
$vector_weight = 0.75;  // ベクトル検索(意味的類似度)の重み

$searchParams = [
    'index' => 'powercmsx_site_v2',
    '_source' => ['title', 'url', 'content', 'excerpt', 'model', 'published_on'],
    'body' => [
        'query' => [
            'script_score' => [
                // 🔹 ここでまずBM25検索(タイトルと本文にマッチするものを絞る)
                'query' => [
                    'bool' => [
                        'must' => [
                            [
                                'multi_match' => [
                                    'query'    => $queryText,
                                    'fields'   => ['title^2', 'content'],
                                    'operator' => 'or',
                                    'minimum_should_match' => '75%',
                                ]
                            ],
                            ['exists' => ['field' => 'embedding']],
                        ]
                    ]
                ],
                'script' => [
                    // 🔹 BM25スコアとベクトル類似度をブレンド
                    'source' => "
                        double base = {$bm25_weight} * _score + {$vector_weight} * cosineSimilarity(params.query_vector, 'embedding');
                        return base;
                    ",
                    'params' => [
                        'query_vector' => $embedding,
                    ],
                ],
            ],
        ],

        'sort' => [
            ['_score' => ['order' => 'desc']],
            ['published_on' => ['order' => 'desc']]
        ],

        'size' => empty($args['limit']) ? 10 : (int)$args['limit'],
    ],
];

$response = $client->search($searchParams);
BM25とベクトル検索を組み合わせたハイブリッド検索の例

実際の効果

「採用実績」で「導入事例」が自然にヒットするなど、ユーザーがどんな言葉で検索しても「ちゃんと見つかる」感覚が得られました。

AIによる意味検索は非常に強力ですが、魔法ではありません。ベクトル化された情報の素材はあくまでコンテンツ本文です。記事が短すぎたり内容が抽象的すぎると、意味ベクトルの学習もうまく機能しません。

ポイント
検索精度を上げるには、AIの前にまず“伝わるコンテンツ”を作ろう。

発展:チャット型QA(RAG構成)へ

さらに一歩進めて、Elasticsearchで検索した内容をOpenAIに要約させる RAG(Retrieval-Augmented Generation ─ 検索拡張生成) に対応しました。例えば「アクセシビリティチェックはできますか?」のような質問に対し、関連ドキュメントをもとにAIが自然文で回答します。

ユーザー質問
      ↓
Normalize(MeCab)
      ↓
Embedding生成(OpenAI)
      ↓
Elasticsearch検索(Hybrid)
      ↓
上位5件をLLMに与える
      ↓
OpenAI(gpt-4o-mini) が自然文で回答
RAG構成のチャット型QAの流れ

チャット型検索を実行した画面

PowerCMS Xでの実装例

public function elasticsearchChatFunction($args, $ctx)
{
    $queryText = $args['query'] ?? '';
    if (!$queryText) {
        return '質問を入力してください。';
    }

    $client = ClientBuilder::create()
        ->setHosts(['https://elastic.example.jp:9200'])
        ->setApiKey($_ENV['ELASTICSEARCH_API_KEY'])
        ->build();

    $openai = OpenAI::client($_ENV['OPENAI_API_KEY']);

    // RAG用に取得するドキュメント数
    $maxDocs = 5;

    // 1️⃣ Embedding生成
    $normalizedQueryText = $this->normalizeForEmbedding($queryText);
    $emb = $openai->embeddings()->create([
        'model' => 'text-embedding-3-small',
        'input' => $normalizedQueryText,
    ]);
    $queryVec = $emb['data'][0]['embedding'];

    // 2️⃣ Elasticsearchから関連文書を取得
    $searchParams = [
        'index' => 'powercmsx_site_v2',
        '_source' => ['title', 'url', 'content'],
        'body' => [
            'query' => [
                'script_score' => [
                    'query' => [
                        // 🔹 ここでまずBM25検索(タイトルと本文にマッチするものを絞る)
                        'multi_match' => [
                            'query'    => $normalizedQueryText,
                            'fields'   => ['title^2', 'content'],
                            'operator' => 'or',
                            'minimum_should_match' => '70%',
                        ]
                    ],
                    'script' => [
                        // 🔹 BM25スコアとベクトル類似度をブレンド
                        'source' => "
                            double bm25 = _score;
                            double vector = cosineSimilarity(params.query_vector, 'embedding');
                            // ベクトルの影響を控えめ(15%程度)
                            double hybrid = (0.85 * bm25) + (0.15 * vector * bm25);
                            return hybrid;
                        ",
                        'params' => [
                            'query_vector' => $queryVec,
                        ],
                    ],
                ],
            ],
            'sort' => [
                ['_score' => ['order' => 'desc']],
                ['published_on' => ['order' => 'desc']]
            ],
            'size' => $maxDocs,
        ],
    ];
    $res = $client->search($searchParams);
    $hits = $res['hits']['hits'] ?? [];

    if (empty($hits)) {
        return '該当する情報が見つかりませんでした。';
    }

    // 3️⃣ LLMに要約させて回答生成
    $context = '';
    foreach ($hits as $h) {
        $src = $h['_source'];
        $context .= "タイトル: {$src['title']}\n本文: {$src['content']}\nURL: {$src['url']}\n\n";
    }

    $developerPrompt = <<<DEVELOPER
あなたはPowerCMS Xサイトの賢いチャットボットです。
以下のルールに従って回答してください:
- 提供された参照情報を基に、正確に答えてください
- もし回答中に見出しを付ける必要がある場合はh2タグ(## 見出し)から使用してください。(見出しは必須ではありません。)
- URLで誘導できる場合は<a target="_blank" href="URL">リンク</a>を張ってください(リンク先のタイトルが入るのが望ましいです)
- わからない場合は「ごめんなさい、わかりません」と答えてください
- 回答は日本語で行ってください
DEVELOPER;

    $userPrompt = <<<USER
【参照情報】
$context

【質問】
$queryText
USER;

    $chat = $openai->chat()->create([
        'model' => 'gpt-4o-mini',
        'messages' => [
            ['role' => 'system', 'content' => $developerPrompt],
            ['role' => 'user', 'content' => $userPrompt]
        ]
    ]);
    $answer = trim($chat['choices'][0]['message']['content']);

    // 4️⃣ HTML整形(Markdown → HTML変換)
    //    Markdown形式のテキストをHTMLに変換する独自メソッドです
    $html = $this->convertMarkdownToHtml($answer);
    return $html;
}
Elasticsearchで検索した内容をOpenAIに要約させるRAG構成の例

まとめ

  • PowerCMS X × Elasticsearch × OpenAI Embeddingで自然文検索を実現しました
  • 手作業で同義語を登録しなくても、AIが意味を理解して検索できます
  • MeCab前処理やBM25の併用で検索結果の品質を最適化しました
  • 「言葉」ではなく「意図」で探す検索体験を提供します
  • RAG対応で、検索した根拠から自然文のチャット回答が可能になりました

これからもユーザーの意図に寄り添った検索体験が提供できるように研究していきたいと思います。