これは「関連記事表示 by Vector Search 2.0」のデモサイトです。記事コンテンツはソリューションブログから引用しています。
Craft Vector SearchとKARTE Blocksを使って、サイト内検索結果が0件の場合にセマンティック検索の結果を表示する方法

Craft Vector SearchとKARTE Blocksを使って、サイト内検索結果が0件の場合にセマンティック検索の結果を表示する方法

こんにちは、Customer Engineerの神谷です!今年の夏休みは、南アフリカに行こうかと計画中です。私は以前もアフリカに行ったことがあり、その際は雄大な大自然や大草原を走り回る野生動物をたくさん見られて感動しました。今回もそんな大冒険に今からワクワクドキドキしています。

さて今回は、Craft Vector Searchを活用した、ちょっと面白いアイデアをご紹介します。Craft Vector Searchに関して詳しくは、以下の記事をご覧ください。

参考:Craft RAG と Craft Vector Search は、どう使い分ければいいのか?

ここでも端的に書くと、Craft Vector Searchは、ベクトル検索技術を活用した高度な検索機能です。もう少し具体的に書くと、単なるキーワード一致検索ではなく、そのキーワードの意図を汲み取ったセマンティック検索と呼ばれるものを実現できます。その検索結果をタグやカテゴリによって絞り込んだり、更にはキーワード検索とセマンティック検索を組み合わせることなども可能です。

この「意図を汲み取る」力が、サイトに訪れるユーザーにどんな変化をもたらすでしょうか?

ここで少し想像してみてください。例えばユーザーが検索窓に「おもてなし 事例」と打ち込んだ際、これまでのキーワード検索では、記事内に「おもてなし」の単語が正確に含まれていないとダメでした。いくら中身が素晴らしい「接客事例」の記事であっても「結果は0件」と返されてしまい、ユーザーにその記事の存在を知ってもらうことすらできません。

しかし、セマンティック検索なら「おもてなし」と「ホスピタリティ」や「顧客満足」などが近い意味であることを理解できます。それにより、キーワードが完全一致しなくても、ユーザーが本当に求めている内容や背景をAIが推察し、「もしかして、こんな記事をお探しではありませんか?」と、まるでコンシェルジュのようにそっと手を差し伸べることができるのです。

そこで今回は、Craft Vector SearchとKARTE Blocksを使って、サイト内検索結果が0件の場合にセマンティック検索の結果を表示する方法をご紹介します。

このソリューションを活用することで、キーワード検索で1件も記事がヒットしなくても、AIがユーザーの検索意図を汲み取って、それに近しい内容の記事を提示できます。それにより、単に検索に何もヒットせず終わり、ユーザーにとってもちょっとガッカリで終わることなく、その次のアクションも促せます。その結果として、サイト上でのより良いユーザー体験を提供することに繋げられるでしょう。

ちなみに今回、このソリューションを実装するにあたって、通常のポップアップのようなKARTEの接客(Action)ではなく、敢えてKARTE Blocks(以下Blocks)を採用しました。それには、サイト改善における以下のメリットがあるからです。

本ソリューションでBlocksを活用するメリット

1. サイトの一部として「自然」に溶け込む

検索結果0件時のレコメンドは、一時的なキャンペーンではなく、サイトの恒久的な機能であるべきです。BlocksはサイトのDOM要素を直接書き換えるため、後出しのポップアップのようなちらつきがなく、最初からそこにあったかのような自然なユーザー体験を提供できます。

2. 「サイト改善」に特化したA/Bテストと分析

「AIが選んだ記事」を単にサイト上に表示するだけでは、もしかしたら不十分かもしれません。

  • どこに表示すれば多くクリックされるか?
  • いくつの記事を表示すればユーザーの関心を惹きやすいか?

Blocksなら、これらをブロック単位で簡単にA/Bテストし、UU数やクリック率などをダイレクトに測定できます。単に「作って終わり」ではなく、データを見ながら継続的に施策の質を高めていけるのが強みです。

アウトプットイメージ

最終的なアウトプットイメージは次の通りです。今回は弊社プレイドのオウンドメディアであるCX Clipの検索結果表示画面に、Craft Vector Searchを利用したAIによるセマンティック検索の結果を表示してみました。

このセマンティック検索に利用する記事のデータについては、別途用意する必要があります。今回は以下のようなデータをKARTE Datahub(以下Datahub)に用意しました。

構成図は次の通りです。1つにまとめるとやや複雑なので、データを投入するものと取得するものの2つに分けて説明します。

データを投入する場合の構成図

  • まず最初に、利用する記事のデータがDatahubに投入されていることを確認します
  • Datahubでクエリを書いてそれをジョブフロー経由で実行し、Craft Functionsに1記事ずつデータを渡します
  • Craft Functionsでは1記事ずつのデータをCraft AI Modulesを利用してベクトル化し、Craft Vector Searchに格納します
    • 記事のIDに該当するURL以外のデータはCraft KVSに格納します
    • 現状のCraft Vector Searchの仕様上、検索結果には記事IDに該当するものと類似度のスコアだけが返却されるため、記事のタイトルなどはCraft KVSに格納してそれを利用します

データを取得する場合の構成図

  • こちらでは、まずユーザーがサイト内検索結果で1件もヒットしないキーワードを入力します
  • 検索結果が0件であることをきっかけにBlocksの施策で記述したスクリプトが起動し、データ投入用とは別のCraft Functionsに検索キーワードを渡して起動します
  • このCraft Functionsでは、検索キーワードを基にCraft Vector Searchから類似する記事のIDを取得し、更にCraft KVSに格納されたタイトルや本文などと合わせてBlocks側にレスポンスを返します
  • Blocks側では、Craft Functionsからレスポンスを受け取ってサイト内にセマンティック検索の結果を表示します

注意点

前提として、次の点に注意をしてください。

  • KARTE Blocks、KARTE Datahub、Craft Functions、Craft Vector Search、Craft AI Modules、Craft KVSを利用できるKARTEプロジェクトが必要です
  • AIによるレコメンド結果が、必ずしも正しいとは限らないことにご注意ください
    • この点は、本ソリューションに限らず注意が必要です
  • 記事データはDatahubに投入済みの前提で話を進めます
    • URL・タイトル・ディスクリプション・本文を含む記事データを別途準備しておいてください

設定手順

設定の手順は次の通りです。

  1. Craft Vector Searchのインデックスを作成する
  2. 記事データ抽出用のクエリをDatahubで作成する
  3. Craft Vector Searchに記事データを投入するためのCraft Functionsを作成する
  4. クエリ結果をデータ投入用Craft Functionsに連携するためのジョブフローを作成する
  5. Craft Vector Searchから検索意図にあったデータを取得するためのCraft Functionsを作成する
  6. Blocksで施策を作成し、検索意図にあったデータを取得するためのCraft Functionsを呼び出すスクリプトを作成する

順番に見てみましょう。

1. Craft Vector Searchのインデックスを作成する

まず最初に、データの投入先であるCraft Vector Searchのインデックスを作成しましょう。既にCraft Vector Searchのインデックスを作成済みの場合は、それを流用できます。ただしその次元数が同じであることが条件で、その場合はパーティション名を変えることで別々のデータを投入して検索対象にできます。

データ投入そのものの基本プロセスは、以下の記事の手順とほぼ同じです。

参考:KARTE Datahub上のデータでCraft Vector Searchのベクトルデータを更新する

ただし今回は、あくまでもテキストの検索に特化したものだったり、結果として返ってくるものに記事のIDに該当するURLに加え、タイトル・ディスクリプション・本文も必要となるため、手順3で出てくるデータ投入用Craft Functionsの中身は、上の記事のものとは少し異なります。それゆえ、この記事でも省略せずに改めて1つ1つ手順を説明します。

  • [すべてのメニュー > Craft > ベクターサーチ > 新規作成] をクリックします
  • 次の項目を設定してインデックスを作成します
    • [名前] : 命名規則に従って任意の名称を入力します
    • [次元数] : 768を設定します
  • 作成されたインデックスの「インデックスId」をコピーしておいてください
    • ただしインデックス作成には30分程度かかることがあるので、作成後しばらく経ってからページをリロードしてみてください
    • インデックス作成に成功すれば、右側の三点リーダーから「インデックスId」をコピーできます

2. 記事データ抽出用のクエリをDatahubで作成する

続いて、記事データを抽出してCraft Vector Searchに投入するためのクエリを作成していきます。まず以下のように、記事データが既にDatahubに入っていることを確認してください。

Datahubにデータが入っていることを確認できたら、以下の手順に進みましょう。

  • [すべてのメニュー > Datahub > クエリ] からクエリ作成画面を開きます
  • 以下のようなサンプルクエリをエディタに貼り付けて保存してください
    • カラム名やテーブル名は皆さんの環境に合わせて適宜変更してください
SELECT
  url AS article_id,
  REPLACE(REPLACE(REPLACE(title, ',', '###'), '\n', ' '), '\r', ' ') AS article_title, -- カンマ(###)に加えて、改行(\n, \r)をスペースに置換
  REPLACE(REPLACE(REPLACE(description, ',', '###'), '\n', ' '), '\r', ' ') AS article_desc,
  REPLACE(REPLACE(REPLACE(CONCAT(title, ' ', content), ',', '###'), '\n', ' '), '\r', ' ') AS search_text, -- 検索用テキストは title と content を結合したあと、改行を消して「1つの長い一行」にする
  'false' AS __delete
FROM `your_table_name` -- テーブル名は実際にデータが入っているものに変更してください

1つ注意点として、タイトルや本文などの中にカンマや改行などが含まれていると、ジョブフロー実行時にその部分で結果が途切れてしまい、1記事ずつの正しいデータを渡せなくなってしまいます。そこで今回は、テキスト内にカンマや改行が入っている場合は一旦別の文字に変換し、後ほどデータを投入する際にデータ投入用のCraft Functions側で元に戻す処理を入れています。

3. Craft Vector Searchに記事データを投入するためのCraft Functionsを作成する

記事データ投入用のCraft Functionsのファンクションを作成します。手順の冒頭で書いたように、今回は既存のテンプレートをアレンジして利用しています。なのでそのテンプレートを利用しつつ、コードの中身や変数は新たに紹介するものに書き換えてください。

  • [Craft > ファンクション > 新規作成 > テンプレートから作成] を選択します

  • KARTE Datahub上のデータでCraft Vector Searchのベクトルデータを更新する 」というテンプレートを検索し[取得]ボタンをクリックします

  • [反映] ボタンをクリックします

  • index.jsの中身を、以下のように書き換えてください

const LOG_LEVEL = '<% LOG_LEVEL %>';
const INDEX_ID = '<% INDEX_ID %>';
const DIMENSION_NUM = Number('<% DIMENSION_NUM %>');
const HEADER_COLUMNS = '<% HEADER_COLUMNS %>';
const ID_FIELD = '<% ID_FIELD %>';
const TEXT_FIELDS = '<% TEXT_FIELDS %>';
const PARTITION_NAME = '<% PARTITION_NAME %>';
const RETRY_TIMEOUT_SEC = 3600;
const DELETE_FLAG_FIELD = '__delete';
const KVS_EXPIRE_MINUTES = 44640;

function throwSuitableError({ msg, status, RetryableError, retryTimeoutSec }) {
  const isRetryable = status && ((status >= 500 && status < 600) || [408, 429].includes(status));
  if (isRetryable) {
    throw new RetryableError(`[retry] ${msg}`, retryTimeoutSec);
  }
  throw new Error(msg);
}

function parseColumnsConfig(headerColumns) {
  return headerColumns
    .split(',')
    .map(v => v.trim())
    .filter(v => v);
}
const HEADER_COLUMNS_CONFIG = parseColumnsConfig(HEADER_COLUMNS);

function parseDatahubRow(value) {
  const splitData = value.split(',');
  return HEADER_COLUMNS_CONFIG.reduce((acc, column, j) => {
    const rawValue = splitData[j] || '';
    acc[column] = rawValue.replace(/###/g, ',');
    return acc;
  }, {});
}

async function generateTextEmbedding(text, aiModules, logger) {
  try {
    const result = await aiModules.gcpEmbeddingsText({
      model: 'text-embedding-004',
      instances: [
        {
          content: text,
          task_type: 'RETRIEVAL_DOCUMENT',
        },
      ],
      parameters: {
        outputDimensionality: DIMENSION_NUM,
        autoTruncate: true,
      },
    });

    if (!result?.predictions?.[0]) {
      throw new Error('Invalid response from gcpEmbeddingsText');
    }

    return result.predictions[0].embeddings.values;
  } catch (err) {
    logger.error(`Failed to generate text embeddings: ${err.message}`);
    throw err;
  }
}

export default async function (data, { MODULES }) {
  const { initLogger, aiModules, vectorSearch, RetryableError } = MODULES;
  const logger = initLogger({ logLevel: LOG_LEVEL });

  if (data.kind !== 'karte/jobflow') return;

  const { value } = data.jsonPayload.data;
  const rowData = parseDatahubRow(value);
  const url = rowData[ID_FIELD];

  if (!url) return;

  if (rowData[DELETE_FLAG_FIELD]?.toLowerCase() === 'true') {
    try {
      await vectorSearch.removeWithKvs({
        indexId: INDEX_ID,
        datapointIds: [url],
        partition: `${PARTITION_NAME}_text`,
      });
      logger.log(`Successfully removed with KVS: ${url}`);
      return;
    } catch (err) {
      throwSuitableError({
        msg: err.message,
        status: err.status,
        RetryableError,
        retryTimeoutSec: RETRY_TIMEOUT_SEC,
      });
    }
  }

  const contentText = (rowData[TEXT_FIELDS] || '').trim().substring(0, 5000);
  if (!contentText) return;

  try {
    const featureVector = await generateTextEmbedding(contentText, aiModules, logger);
    const textPartition = `${PARTITION_NAME}_text`;

    await vectorSearch.upsertWithKvs({
      indexId: INDEX_ID,
      datapoints: [
        {
          datapoint_id: url,
          feature_vector: featureVector,
          data: {
            title: rowData.article_title || '',
            description: (rowData.article_desc || '').substring(0, 300),
          },
        },
      ],
      kvsMinutesToExpire: KVS_EXPIRE_MINUTES,
      partition: textPartition,
    });

    logger.log(`Processing completed (WithKvs) for: ${url}`);
  } catch (err) {
    logger.error(`Failed to process ${url}: ${err.message}`);
    throwSuitableError({
      msg: `Process failed: ${err.message}`,
      status: err.status,
      RetryableError,
      retryTimeoutSec: RETRY_TIMEOUT_SEC,
    });
  }
}
  • [設定 > ファンクションのタイプ] で「イベント駆動タイプ」を選択します
  • [変数] タブで次の変数の値を設定してください
    • LOG_LEVEL
      • 出力するログのレベルを、ERROR/WARN/INFO/DEBUGから指定します
    • INDEX_ID
      • 作成したCraft Vector SearchのインデックスIDを指定します
    • DIMENSION_NUM
      • 作成したCraft Vector Searchのインデックスの次元数を指定します
      • 今回の例では768を指定しています
    • HEADER_COLUMNS
      • データ抽出用クエリ結果の列名をカンマ区切りで指定します
      • 今回の例ではarticle_id,article_title,article_desc,search_text,__deleteと指定しています
    • ID_FIELD
      • クエリ結果のフィールドの中で、Craft Vector Search側でdatapoint_idとして使用するフィールド名を指定します
      • 今回の例ではarticle_idを指定しています
    • TEXT_FIELDS
      • クエリ結果のフィールドの中で、ベクトル化対象のテキストが含まれるフィールド名をカンマ区切りで指定します
      • 今回の例ではsearch_textを指定しています
    • PARTITION_NAME
      • 検索対象のパーティション名のベースとなる任意の名前を指定します
    • その他の変数は、ひとまずデフォルト値のままで問題ありません
  • 適当なファンクション名をつけて [デプロイ] します

なお、テンプレートのソースコードはGitHubで公開しています。

craft-codes/craft-functions/datahub-to-vectorsearch at main · plaidev/craft-codes

4. クエリ結果をデータ投入用Craft Functionsに連携するためのジョブフローを作成する

続いて、前の手順3で作成した記事データ投入用Craft Functionsに記事のデータを1つずつ渡して処理をしてもらうためのジョブフローを作成しましょう。

  • KARTEのグロナビから[Datahub > ジョブフロー] の順に遷移して右上の作成ボタンをクリックします
  • 以下のように、ジョブフローの設定を行ってください
    • クエリの選択では、1つ前で作成したクエリを選択してください
    • エクスポート先には外部サービスを選択してください
      • 接続先にはCraftを選択してください
      • function名には、手順3で作成したデータ投入用Craft Functionsの名称を入力してください
      • 行ごとにqueueをpublishするにはチェックを入れてください
    • ジョブ名には適当な名称をつけて完了ボタンを押してください

行ごとにqueueをpublishするにチェックを入れることで、Craft Functionsで1行ずつ、すなわち1記事分ずつ処理を実行することが可能になります。それと、今回は検証なのでジョブフローは手動で実行しています。もし実運用の際に、定期的に利用する記事データが増える場合は、スケジュール実行の設定を行うといいでしょう。ジョブフローのスケジュール実行について詳しくは、こちらのドキュメントをご覧ください。

5. Craft Vector Searchから検索意図にあったデータを取得するためのCraft Functionsを作成する

今度はCraft Vector Searchからデータを取得するためのCraft Functionsのファンクションを作成します。これも既存のテンプレートのアレンジで実現できるので、それを利用する形にします。

  • [Craft > ファンクション > 新規作成 > テンプレートから作成] を選択します

  • Craft Vector Searchのベクトルデータを検索する 」というテンプレートを検索し[取得]ボタンをクリックします

  • [反映] ボタンをクリックします

  • index.jsの中身を、以下のように書き換えてください

const LOG_LEVEL = '<% LOG_LEVEL %>';
const ALLOWED_ORIGINS = '<% ALLOWED_ORIGINS %>';
const ENDPOINT_ID = '<% ENDPOINT_ID %>';
const DIMENSION_NUM = Number('<% DIMENSION_NUM %>');
const PARTITION_NAME = '<% PARTITION_NAME %>';

async function getTextEmbedding(text, aiModules, logger) {
  try {
    const result = await aiModules.gcpEmbeddingsText({
      model: 'text-embedding-004',
      instances: [
        {
          content: text,
          task_type: 'RETRIEVAL_QUERY',
        },
      ],
      parameters: {
        outputDimensionality: DIMENSION_NUM,
      },
    });
    return result.predictions[0].embeddings.values;
  } catch (error) {
    logger.error('Failed to get text embedding:', error);
    throw error;
  }
}

function setCorsHeaders(res, origin, allowedOrigins) {
  const origins = allowedOrigins.split(',').map(o => o.trim());
  if (origins.includes(origin) || origins.includes('*')) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    return true;
  }
  return false;
}

export default async function (data, { MODULES }) {
  const { res, req } = data;
  const { aiModules, vectorSearch, initLogger } = MODULES;
  const logger = initLogger({ logLevel: LOG_LEVEL });

  const origin = req.headers.origin;
  if (req.method === 'OPTIONS') {
    if (setCorsHeaders(res, origin, ALLOWED_ORIGINS)) res.status(200).end();
    return;
  }
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method Not Allowed' });
  setCorsHeaders(res, origin, ALLOWED_ORIGINS);

  const { query, topK = 3 } = req.body;
  if (!query) return res.status(400).json({ error: "Missing 'query'" });

  try {
    const queryVector = await getTextEmbedding(query, aiModules, logger);
    const targetPartition = `${PARTITION_NAME}_text`;

    const searchRes = await vectorSearch.findNeighborsWithKvs({
      indexEndpointId: ENDPOINT_ID,
      queries: [
        {
          datapoint: {
            feature_vector: queryVector,
            neighborCount: topK,
          },
        },
      ],
      partition: targetPartition,
    });

    const nearestNeighbors = searchRes.nearestNeighbors || [];

    if (nearestNeighbors.length === 0 || !nearestNeighbors[0].neighbors) {
      logger.warn('No neighbors found');
      return res.status(200).json({ query, results: [] });
    }

    const neighbors = nearestNeighbors[0].neighbors;

    const finalResults = neighbors.map(n => ({
      url: n.datapoint.datapointId,
      title: n.data?.title || 'タイトル未取得',
      description: n.data?.description || '',
      score: n.distance,
    }));

    res.status(200).json({
      query,
      results: finalResults,
    });
  } catch (error) {
    logger.error('Search failed:', error);
    res.status(500).json({ error: 'Internal Server Error' });
  }
}
  • [設定 > ファンクションのタイプ] で「HTTPタイプ」を選択します
  • [変数] タブで次の変数の値を設定してください
    • LOG_LEVEL
      • 出力するログのレベルを、ERROR/WARN/INFO/DEBUGから指定します
    • ALLOWED_ORIGINS
      • Webページから該当のCraft Vector Searchに登録したデータを検索する際、それを許可するサイトのURLを指定します
      • 今回の例ではhttps://cxclip.karte.io/を指定しています
      • 検証の際は全てのサイトを許可する*でもかまいませんが、本番運用の際は注意してください
    • ENDPOINT_ID
      • データ投入時に使用したCraft Vector SearchインデックスのエンドポイントIDを指定します
      • 手順3で利用したインデックスIDとは別物であることに気をつけてください
    • DIMENSION_NUM
      • データ投入時に使用したCraft Vector Searchのインデックスの次元数を指定します
      • 手順3で利用したものと同じ値を入力するので、今回は768と入力しています
    • PARTITION_NAME
      • Craft Vector Searchへのデータ投入時に設定した、検索対象のパーティション名のベースとなる名前を指定します
      • 手順3で入力した名称と同じものを指定する必要があります
    • その他の変数は、ひとまずデフォルト値のままで問題ありません
  • 適当なファンクション名をつけて [デプロイ] します
  • デプロイ完了後に[設定] タブにエンドポイントURLが表示されるので、それをメモしておいてください
    • デプロイ完了してもエンドポイントURLが表示されない場合は、ページをリロードしてみてください

なお、テンプレートのソースコードはGitHubで公開しています。

craft-codes/craft-functions/search-for-vectorsearch-data at main · plaidev/craft-codes

6. Blocksで施策を作成し、検索意図にあったデータを取得するためのCraft Functionsを呼び出すスクリプトを作成する

最後に、サイト側でキーワード検索結果が0件の場合に1つ前の手順で作成したCraft Functionsのエンドポイントにリクエストを飛ばし、セマンティック検索の結果を取得してサイトに表示するのに必要な施策設定をBlocksで行います。これはサイトによって実際のスクリプトが多少異なるものになる場合もあるため、あくまでも参考としてご覧ください。

それと2026年3月現在、Blocksはリデザイン化が進行中で、以前の画面のものとは見た目が異なります。弊社のCX Clipで利用している環境では既にリデザインが済んでいるため、今回はその前提で説明します。Blocksのリデザインに関する詳細は、こちらのドキュメントをご覧ください。

  • [Blocks > 施策一覧 > 施策を作成]をクリックします
  • まず配信ページでどのページに配信するのか設定を行います
    • 今回は検証なので検索結果ページのURLにハッシュとしてblocks_test=trueを含む場合のみとしました
    • ここは皆さんのサイトに合わせて適当に調整してください
  • 続いてパターンとブロックでページ内のどの部分にセマンティック検索結果を表示するのか、該当部分のブロックを追加します
    • 既に該当部分のブロックが存在する場合はそれを利用できますし、ない場合は新たに追加してください
  • 配信割合を適当に設定し、ブロックを書き換える場合のパターンAの右側にある編集ボタンをクリックします
  • 該当ブロックの編集画面で[ビジュアル > コード]の順に開き、その中にあるSCRIPTタブをクリックします
  • その中身を以下のように書き換えて変更を確定ボタンをクリックします
    • 既にお伝えしたように、あくまでもこれはサンプルなので特にセマンティック検索の結果を表示する部分のレイアウトなどは、皆さんのサイトに合わせて変更してください
(function() {
  'use strict';

  // --- 設定 ---
  const CONFIG = {
    functionsUrl: 'https://xxxxxxxx.cev2.karte.io/functions/yyyyyyyyyy', // 実際のCraft FunctionsのエンドポイントURLに書き換えてください
    
    // サイトごとのCSSセレクタ設定
    selectors: {
      countElement: '.gz69cgc',    // 検索結果件数が表示されている要素
      noResultsArea: '.gz69cgd',   // 0件時に表示されるメッセージエリア
      mainContainer: 'main'        // フォールバック用のメインコンテナ
    },
    
    // 0件と判定するテキスト(サイトによって "0" だったり "検索結果はありません" だったりするため)
    noResultsText: '0',
    
    debugLog: true
  };

  // 特殊文字のエスケープ
  function escapeHtml(str) {
    if (!str) return '';
    return str.replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
  }

  const init = async function() {

    const countElement = document.querySelector(CONFIG.selectors.countElement);
    const noResultsArea = document.querySelector(CONFIG.selectors.noResultsArea);
    
    const isNoResults = (countElement && countElement.textContent.trim() === CONFIG.noResultsText) || noResultsArea;

    if (!isNoResults) {
      console.log('検索結果が存在するか、0件画面ではないと判断しました');
      return;
    }

    const urlParams = new URLSearchParams(window.location.search);
    const keyword = urlParams.get('keyword');
    if (!keyword) return;

    try {
      const response = await fetch(CONFIG.functionsUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query: keyword })
      });
      
      const data = await response.json();
      const results = data.results || [];

      if (results.length > 0) {
        const target = noResultsArea || document.querySelector(CONFIG.selectors.mainContainer);
        displayResults(results, keyword, target);
      }
    } catch (error) {
      console.log('エラーが発生しました:', error);
    }
  };

  function displayResults(results, keyword, targetElement) {
    const displayItems = results.slice(0, 3);
    
    // 既存メッセージの書き換え(targetElement自体、またはその中のpタグ)
    const messagePara = targetElement.querySelector('p') || targetElement;
    messagePara.innerHTML = `<span style="color: #2aab9f; font-weight: bold; font-size: 1.2rem; display: block; margin-bottom: 10px;">「${escapeHtml(keyword)}」に近いテーマの記事をAIが選定しました</span>`;

    const html = `
      <div id="vector-recommend-area" style="display: block !important; visibility: visible !important; margin-top: 2rem;">
        <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
          ${displayItems.map(item => `
            <a href="${item.url}" style="text-decoration: none; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.5rem; background: #fff; box-shadow: 0 4px 6px rgba(0,0,0,0.05); transition: all 0.3s ease; display: flex; flex-direction: column; justify-content: space-between;" 
               onmouseover="this.style.borderColor='#2aab9f';this.style.boxShadow='0 10px 15px rgba(42,171,159,0.1)';" 
               onmouseout="this.style.borderColor='#e2e8f0';this.style.boxShadow='0 4px 6px rgba(0,0,0,0.05)';">
              <div>
                <h3 style="margin: 0 0 10px 0; color: #1a202c; font-size: 1.1rem; line-height: 1.4; font-weight: bold;">${escapeHtml(item.title)}</h3>
                <p style="margin: 0; color: #4a5568; font-size: 0.85rem; line-height: 1.6; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;">${escapeHtml(item.description)}</p>
              </div>
              <span style="margin-top: 15px; color: #2aab9f; font-size: 0.8rem; font-weight: bold;">記事を読む →</span>
            </a>
          `).join('')}
        </div>
      </div>
    `;

    targetElement.insertAdjacentHTML('beforeend', html);
    console.log('DOMへの挿入を完了しました');
  }

  if (document.readyState === 'loading') {
    window.addEventListener('load', init);
  } else {
    init();
  }
})();
  • ここまでできたら施策を公開してください

実際に動かしてみる

実際に動作を確認してみましょう。

まずはCraft Vector Searchに投入するための記事データが、Datahubに入っていることを確認します。

その後クエリを書いてジョブフロー経由でその記事データをCraft Functionsに渡して起動し、Craft Vector Searchに投入します。そしてその記事データをセマンティック検索結果として取得するためのCraft Functionsを作成し、そのエンドポイントURLをBlocksのスクリプトに設定します。

そして実際にサイト側で、キーワード検索だと1件もヒットしないものを入力して検索してみます。すると以下のように、入力されたキーワードの検索意図に近しいとAIが判別した記事が表示されるはずです。

おわりに

今回はCraft Vector SearchとKARTE Blocksを使って、サイト内検索結果が0件の場合にセマンティック検索の結果を表示する方法について紹介しました。

このソリューションを活用することで、単なるキーワード検索に留まらず、その意図を汲み取った結果を表示できて面白いですよね。これによって、1件も記事がヒットしなくてユーザーをガッカリさせるのではなく、ユーザーに対して新たなコンテンツ提案を行い、より良いユーザー体験に繋げられるでしょう。

ぜひ皆さんも試してみてください!