1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

プリザンターの検索は、FullText 列を作ってキーワードで引く仕組みです。仕組みそのものは
プリザンターの検索機能を詳しくみてみる」で追いかけましたが、
キーワード検索には原理的な限界があります。言葉が違うと引っかからない、という点です。

たとえば問い合わせ管理テーブルに、

  • 「複合機で印刷すると紙詰まりエラーになる」

という過去案件があったとして、新しく来た問い合わせが

  • 「2階のプリンタが紙づまりで止まる」

だった場合、共通する単語がほとんどありません。「複合機」と「プリンタ」、「紙詰まり」と「紙づまり」。
キーワード検索では、この 2 件は結びつきません。

この記事では さくらのAI Engine の埋め込み API を使って、
意味が近いレコードを探す仕組みを拡張機能だけで作ります。DB 拡張(pgvector 等)は使いません。

バージョン 1.5.7.0 を対象にしています

埋め込み(Embedding)とは

文章を固定長の数値ベクトルに変換したものです。意味が近い文章ほど、ベクトルの向きが近くなります。
2 つのベクトルの「向きの近さ」はコサイン類似度で測れます。

さくらのAI Engine では multilingual-e5-large が使えます。

項目
エンドポイント https://api.ai.sakura.ad.jp/v1/embeddings
モデル multilingual-e5-large
次元数 1024
認証 Authorization: Bearer <UUID>:<シークレット>
curl 'https://api.ai.sakura.ad.jp/v1/embeddings' \
  -H 'Authorization: Bearer {{さくらのAI Engineのトークン}}' \
  -H 'Content-Type: application/json' \
  -d '{"model":"multilingual-e5-large","input":["テスト文です"]}'

レスポンスの data[0].embedding に 1024 個の数値が入っています。

全体像

ベクトルを置く場所が問題になりますが、今回は長文項目に JSON 文字列として持ちます
1024 個の浮動小数点数を JSON にすると 19KB 程度で、長文項目に収まります。

DB 拡張を使わないので、SQL Server / PostgreSQL / MySQL のどれでも同じコードが動きます

下準備: テーブルと項目

記録テーブルで 問い合わせ管理 を作り、説明項目を 3 つ使います。

物理名 表示名 用途 設定
DescriptionA 問い合わせ内容 検索対象の本文
DescriptionC ベクトル 埋め込みの保管先 非表示
DescriptionD 類似案件 検索結果の表示先 読み取り専用

ベクトル は人が見るものではないので、項目の詳細設定で非表示にしておきます。

長文項目は Community Edition では DescriptionADescriptionZ の 26 個です。
Enterprise Edition の項目拡張を使うと Description001Description999 が追加で使えるようになります。
この記事は 26 個の範囲で完結します。

実装

App_Data/Parameters/ExtendedServerScripts/ に置く拡張サーバスクリプトとして実装します。
テーブルの管理のサーバスクリプトではなく拡張サーバスクリプトを使うのは、API トークンを
画面から見えない場所に置くため
です。

App_Data/Parameters/ExtendedServerScripts/SimilarCases.json
{
  "Name": "SimilarCases",
  "Description": "問い合わせ内容をベクトル化し、類似する過去の問い合わせを提示する",
  "SiteIdList": [5],
  "BeforeCreate": true,
  "BeforeUpdate": true
}
App_Data/Parameters/ExtendedServerScripts/SimilarCases.json.js
(function () {
  'use strict';

  var EMBED_ENDPOINT = 'https://api.ai.sakura.ad.jp/v1/embeddings';
  var EMBED_MODEL = 'multilingual-e5-large';
  var TOKEN = '{{さくらのAI Engineのトークン}}';
  var TOP_N = 3;
  // multilingual-e5 は無関係な日本語文でもコサイン類似度が 0.80 前後になる。
  // 絶対値のしきい値では切れないので「1位との差」で足切りする。
  var SCORE_MARGIN = 0.05;

  var text = String(model.DescriptionA == null ? '' : model.DescriptionA).trim();
  if (text === '') {
    return;
  }

  // 1. 自レコードの本文をベクトル化して保存する
  var vec = embed(text);
  if (vec === null) {
    context.AddMessage('ベクトル化に失敗しました。類似案件は更新されません。', 'alert-warning');
    return;
  }
  model.DescriptionC = JSON.stringify(vec);

  // 2. 同じテーブルの既存レコードと総当たりでコサイン類似度を計算する
  var rows = items.Get(context.SiteId);
  var myId = Number(context.Id);
  var scored = [];
  for (var i = 0; i < rows.Length; i++) {
    var r = rows[i];
    if (Number(r.ResultId) === myId) {
      continue;
    }
    var raw = String(r.DescriptionC == null ? '' : r.DescriptionC);
    if (raw === '') {
      continue;
    }
    var other;
    try {
      other = JSON.parse(raw);
    } catch (e) {
      continue;
    }
    if (!other || other.length !== vec.length) {
      continue;
    }
    scored.push({
      id: Number(r.ResultId),
      title: String(r.Title == null ? '' : r.Title),
      type: String(r.ClassA == null ? '' : r.ClassA),
      score: cosine(vec, other)
    });
  }

  scored.sort(function (a, b) {
    return b.score - a.score;
  });

  // 3. 1位との差が SCORE_MARGIN 以内のものだけを、上位 TOP_N 件まで書き戻す
  if (scored.length === 0) {
    model.DescriptionD = '照合できる過去の問い合わせがありません。';
    return;
  }
  var best = scored[0].score;
  var hits = [];
  for (var j = 0; j < scored.length && hits.length < TOP_N; j++) {
    if (best - scored[j].score <= SCORE_MARGIN) {
      hits.push(scored[j]);
    }
  }

  var lines = ['■ 類似する過去の問い合わせ(照合対象 ' + scored.length + ' 件)', ''];
  for (var k = 0; k < hits.length; k++) {
    var h = hits[k];
    lines.push(
      '類似度 ' + h.score.toFixed(3) +
      '  [#' + h.id + '] ' + h.title +
      (h.type === '' ? '' : '' + h.type + '')
    );
    lines.push('  ' + context.ApplicationPath + 'items/' + h.id + '/edit');
  }
  model.DescriptionD = lines.join('\n');

  // ---- ヘルパー ------------------------------------------------------------

  function embed(s) {
    httpClient.RequestUri = EMBED_ENDPOINT;
    httpClient.RequestHeaders.Clear();
    httpClient.MediaType = 'application/json';
    httpClient.Encoding = 'utf-8';
    httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + TOKEN);
    httpClient.TimeOut = 45000;
    httpClient.Content = JSON.stringify({ model: EMBED_MODEL, input: [s] });
    var raw = httpClient.Post();
    if (httpClient.IsTimeOut || !httpClient.IsSuccess) {
      return null;
    }
    try {
      return JSON.parse(raw).data[0].embedding;
    } catch (e) {
      return null;
    }
  }

  // 正規化済みでないベクトルも来るので、内積をノルムで割る
  function cosine(a, b) {
    var dot = 0;
    var na = 0;
    var nb = 0;
    for (var i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      na += a[i] * a[i];
      nb += b[i] * b[i];
    }
    if (na === 0 || nb === 0) {
      return 0;
    }
    return dot / (Math.sqrt(na) * Math.sqrt(nb));
  }
})();

ポイント1: items.Get() の戻り値は .Length

items.Get(context.SiteId) は同じテーブルのレコードを返します。ここで返ってくるのは
.NET の配列なので、length(小文字)ではなく Length(大文字) です。

// ❌ undefined になる
for (var i = 0; i < rows.length; i++) { }

// ✅
for (var i = 0; i < rows.Length; i++) { }

rows.lengthundefined だとループが 1 回も回らず、エラーも出ないまま
「照合対象 0 件」になります。最初にここで詰まりました。

ポイント2: httpClient の状態は他のスクリプトと共有される

httpClient同一リクエスト内で動くすべてのサーバスクリプトが同じインスタンスを共有します。
別のスクリプトが Encoding を変更していると、その状態のまま次のスクリプトのリクエストが飛びます。

実際、検証用スクリプトが Encodingiso-8859-1 にしたまま終了したところ、
埋め込み API に送る日本語が壊れ、類似度が軒並み 0.77 前後に落ちました
値が返ってくるので気づきにくく、原因究明に一番時間がかかったところです。

httpClient.RequestUri = EMBED_ENDPOINT;
httpClient.RequestHeaders.Clear();   // ヘッダも残っている
httpClient.MediaType = 'application/json';
httpClient.Encoding = 'utf-8';       // 明示的に戻す

使う直前に毎回すべて設定し直すのが安全です。

動かしてみる

過去案件を 6 件登録した状態で、新しい問い合わせを起票します。

2階の共用プリンタで資料を印刷しようとしたところ、「紙づまり」と表示されて印刷できません。
トレイを開けて確認しましたが、詰まった用紙は見当たりませんでした。

保存すると 類似案件 が埋まります。

類似案件が表示された編集画面

■ 類似する過去の問い合わせ(照合対象 6 件)

類似度 0.921  [#8] 複合機で印刷すると紙詰まりエラーになる(ハードウェア障害)
  /items/8/edit

「複合機」と「プリンタ」、「紙詰まり」と「紙づまり」——共通する単語がひとつも無いのに
正しく引き当てています。キーワード検索では絶対に出てこない結果です。

しきい値の決め方でハマった

最初は 類似度 0.80 以上 という絶対値で足切りしていました。その結果がこちらです。

類似度 0.921  [#8] 複合機で印刷すると紙詰まりエラーになる
類似度 0.838  [#9] プリンタドライバのインストール手順を知りたい
類似度 0.834  [#13] 一覧画面にCSV出力ボタンを追加してほしい

紙詰まりの問い合わせに対して「CSV出力ボタンの要望」が 0.834 で出てきます。
multilingual-e5 は無関係な日本語文どうしでも 0.80 前後のスコアを返します。
日本語という共通点だけで下駄が履かされるイメージです。

実測したスコアの分布がこちらです。

文書 類似度
紙詰まり(正解) 0.9215
ドライバ手順 0.8374
CSV出力の要望 0.8337
有給休暇の残日数 0.8168

1位と最下位の差はわずか 0.10 しかありません。 絶対値のしきい値は環境ごとに調整が必要で、
そもそも安定しません。そこで「1位のスコアとの差」で切るようにしました。

var best = scored[0].score;
if (best - scored[j].score <= SCORE_MARGIN) { /* 採用 */ }

これで先ほどの例は正解 1 件だけが残ります。

query: / passage: プレフィックスは効かなかった

e5 系のモデルは、検索クエリに query:、検索対象に passage: という接頭辞(末尾に半角スペース)を
付けると精度が上がるとされています。実際に試してみました。

条件 1位 最下位 1位と最下位の差
そのまま 0.9215 0.8168 0.1047
query: / passage: あり 0.8984 0.7950 0.1035

分離幅はむしろ僅かに縮まりました。 少なくともさくらのAI Engine の
multilingual-e5-large では、プレフィックスを付ける意味は無さそうです。
定石を鵜呑みにせず、手元のデータで測ったほうがよいところです。

この方式の限界

総当たりでコサイン類似度を計算しているので、レコード数に比例して遅くなります

レコード数 1 回の保存でやること
数十件 実用範囲。体感で気にならない
数百件 JSON パースが効いてくる。バックグラウンド化を検討
数千件以上 この方式は破綻する

件数が増えたら、次のような手が考えられます。

  • ベクトルを DB 側に持たせる — PostgreSQL なら pgvector、SQL Server 2025 / MySQL 9 以降ならネイティブの VECTOR 型。拡張SQL(extendedSql)から近傍検索クエリを呼べば、計算を DB に寄せられます
  • 候補を先に絞るitems.Get(siteId, view) の第 2 引数にビューを渡して、同じ種別・直近 1 年などで母数を減らしてから総当たりする
  • 保存のたびに再計算しない — 本文が変わったときだけ埋め込みし直す(saved.DescriptionA と比較する)

今回のコードは「拡張機能だけで、DB を選ばず、まず動かす」ことを優先しています。

まとめ

プリザンターにベクトル検索を足しました。ポイントは次のとおりです。

  • 埋め込みは 1024 次元 = JSON で 19KB 程度。長文項目にそのまま入る
  • DB 拡張を使わずコサイン類似度を JS で計算すれば、RDBMS を選ばない
  • items.Get() の戻り値は .NET 配列。length ではなく Length
  • httpClientリクエスト内で共有される。使う直前に毎回すべて設定し直す
  • multilingual-e5 は無関係な文でも 0.80 前後。絶対値のしきい値ではなく「1位との差」で切る
  • query: / passage: プレフィックスは、少なくともこの環境では効果が無かった
  • 総当たりなので数百件が実用上限。増えたら pgvector やビューでの絞り込みへ

キーワード検索と置き換えるものではなく、併用するものです。
「同じ問い合わせが過去に来ていないか」を起票時に自動で出す、という使い方が一番効きます。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?