はじめに
プリザンターの検索は、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 では DescriptionA〜DescriptionZ の 26 個です。
Enterprise Edition の項目拡張を使うと Description001〜Description999 が追加で使えるようになります。
この記事は 26 個の範囲で完結します。
実装
App_Data/Parameters/ExtendedServerScripts/ に置く拡張サーバスクリプトとして実装します。
テーブルの管理のサーバスクリプトではなく拡張サーバスクリプトを使うのは、API トークンを
画面から見えない場所に置くためです。
{
"Name": "SimilarCases",
"Description": "問い合わせ内容をベクトル化し、類似する過去の問い合わせを提示する",
"SiteIdList": [5],
"BeforeCreate": true,
"BeforeUpdate": true
}
(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.length が undefined だとループが 1 回も回らず、エラーも出ないまま
「照合対象 0 件」になります。最初にここで詰まりました。
ポイント2: httpClient の状態は他のスクリプトと共有される
httpClient は同一リクエスト内で動くすべてのサーバスクリプトが同じインスタンスを共有します。
別のスクリプトが Encoding を変更していると、その状態のまま次のスクリプトのリクエストが飛びます。
実際、検証用スクリプトが Encoding を iso-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 やビューでの絞り込みへ
キーワード検索と置き換えるものではなく、併用するものです。
「同じ問い合わせが過去に来ていないか」を起票時に自動で出す、という使い方が一番効きます。
