0
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?

LLM が出力した名前は、マスタと微妙に違う — 表記揺れを前提にした正規化と観測の設計

0
Posted at

この記事は archiningen.com からの転載です。
連載「AI エージェント API の本番設計」の第 4 回 (全 4 回) です。

LLM の自由文がマスタの語彙空間へ射影される流れを示した図。左端に LLM の出力するカテゴリ名が置かれ、右へ向かって 4 つの関門 — エイリアス(1:N 展開)、完全一致、ファジーマッチ(閾値 0.6)、一致なし — を順に通る。各関門の下にはログへの出方が添えられ、exact はログに出さず、alias と fuzzy は変換を記録、none は原因の仮説つきで warn されることを示す。右端では正規化されたカテゴリ名がマスタの行順と 1:1 対応する特徴ベクトルの次元に接続される。図の結びは「射影で受け、ログで観測する」

LLM に「この一覧にある名前から選んで出力して」と指示しても、返ってくる表記は微妙に揺れます。マスタには「コンビニエンスストア」とあるのに、モデルは「コンビニ」と返す。人間が読めば同じものですが、文字列で突き合わせるプログラムにとっては別物です。LLM の出力を既存のマスタやデータベースに繋ごうとした人なら、たいてい一度はぶつかっている問題だと思います。

まず試すのはプロンプトの強化でしょう。「必ず一覧の表記のまま出力すること」と念を押せば、揺れはかなり減ります。ただし、ゼロにはなりません。どの語を選ぶかは指示できても、その語をどう書くかまでは、指示だけでは縛りきれないからです。

多少の揺れが残っても、表示に使うだけなら困りません。しかしこのシステムでは致命傷になります。LLM が出したカテゴリ名がそのまま数値パイプラインに入り、カテゴリ名 1 件が特徴ベクトルの次元 1 つに対応するからです。表記が 1 文字揺れると、それは「意味が少し違う」ではなく次元の不一致として壊れます。

だとすると、設計の前提はこう置くしかありません。揺れは残る。残った揺れは、受け止める側で解決する。 具体的には、出力とマスタの間に揺れた表記をマスタの語彙へ引き戻す正規化の層 — 本記事では「マスタの語彙空間への射影」と呼びます — を挟み、その層がどれだけ働いたかを運用ログで観測し続けます。前回の記事で「受け皿」として 1 行だけ触れた正規化レイヤの、これが詳説です。

題材はこれまでと同じ、B2B の業務 SaaS のバックエンドで動く AI エージェント API(Azure Functions v4 / Node.js 22 / TypeScript)です。ただし本記事の内容はほぼ汎用の Node.js の話で、Azure にもモデルにも依存しません。文字列類似度アルゴリズムの数学的解説と、名寄せ・マスタデータ管理の一般論は書きません。LLM の出力が入力側に来るとき何が変わるかに絞ります。

前提: カテゴリ名が 1 件ずれると、ベクトルの次元が壊れる

分類マスタ(CSV)の行順は、特徴ベクトルの次元インデックスと 1:1 に対応しています。このマスタは 1 つのリポジトリに閉じておらず、ベクトルを生成する ETL リポジトリ A、抽出処理の ETL リポジトリ B、そして本リポジトリの 3 箇所で同じリストが使われます。社内ガイドはこう書いています — 特にリポジトリを跨いだずれが最も危険である。単一リポジトリ内の不整合は検知しやすいが、リポジトリ間のずれは気づきにくく、本番環境で初めて発覚するリスクがある

つまりこのシステムでは、「LLM がマスタに無い表記を出す」は表示の乱れではなく、パイプラインの実行時エラーか、より悪ければ黙って間違った次元を読む事故につながります。プロンプトには「分類マスタの小分類から選定すること」と書いてありますが、前回書いたとおり、プロンプトやスキーマの指示は伝達であって保証ではありません。保証するのは、自由文とマスタの間に挟んだ射影層です。

4 段階の正規化 — エイリアスを完全一致より先に置く理由

射影層の本体は 30 行ほどの関数です。

// src/utils/fuzzyMatch.ts  normalizeValue() より
function normalizeValue(value, cutoff = 0.6) {
  // エイリアスマップを最優先(1:N 展開対応)
  const targets = aliasMap.get(value);
  if (targets) {
    const valid = targets
      .filter((t) => categorySet.has(t))
      .map((t) => ({ resolved: t, method: "alias" }));
    if (valid.length > 0) return valid;
  }

  // 完全一致
  if (categorySet.has(value)) {
    return [{ resolved: value, method: "exact" }];
  }

  // ファジーマッチ
  const match = stringSimilarity.findBestMatch(value, categoryList);
  if (match.bestMatch.rating >= cutoff) {
    return [{ resolved: match.bestMatch.target, method: "fuzzy" }];
  }

  // 一致なし
  console.warn(`fuzzyMatch: no match for '${value}'`);
  return [{ resolved: value, method: "none" }];
}

順序が特徴的です。エイリアスが完全一致より先に来ています。普通に書けば「まず完全一致、外れたらエイリアス」としたくなりますが、逆にしているのには理由があります。通称は 1:N に展開されるべきケースがあるからです。

エイリアスは CSV で管理されています(以下は作例です。実マスタの行ではありません)。

ALIAS,CATEGORY_NAME
カフェ,喫茶店
カフェ,コーヒー専門店
スーパー,スーパーマーケット

「カフェ」という通称は、マスタ上の「喫茶店」と「コーヒー専門店」の 2 カテゴリに対応します。仮にマスタ側にも「カフェ」という正式名が存在した場合、完全一致を先に置くと 1 件だけ拾って終わり、1:N 展開の機会が失われます。エイリアスを先に引くことで、「この通称はこう解釈する」という運用側の意思がマスタの偶然の一致より優先されます。

この CSV には設計判断が 2 つ埋まっています。1 つは、1:N を JSON の配列ではなく行の重複で表現していることです。カフェ の行が 2 行ある、それだけです。理由は運用で、このファイルは運用担当が Excel で編集できる必要があります。ネスト構造を導入した瞬間にエンジニア以外が触れないファイルになります。もう 1 つは、ロード時に正規名がマスタに実在するものだけを登録する(categorySet.has(canonical) で弾く)参照整合性の強制です。マスタからカテゴリを除外したのにエイリアス側の掃除が漏れた、という運用ミスがあっても、存在しないカテゴリへの射影は起きません。

閾値 0.6 を、アルゴリズムを変えたのに据え置いていいのか

3 段目のファジーマッチには前史があります。この正規化はもともと Python の difflib.SequenceMatcher(Gestalt Pattern Matching)で動いていたものを、Node.js の string-similarity(文字 bigram ベースの Dice 係数)へ移植したものです。そして閾値 0.6 は、difflib.get_close_matches()デフォルトの cutoff 値であり、移植時にそのまま据え置かれました(なお string-similarity は現在 npm 上で非推奨(サポート終了)とマークされています。本記事では移植時のアルゴリズム差を示す題材として扱います)。

どちらのスコアも「2 × 共通部分 / 合計」という同じ形をしていますが、共通部分の数え方が違います。difflib は一致した文字数の合計を数え(2.0*M/T の M)、string-similarity は bigram(隣接 2 文字の組)の一致を数えます。日本語のカテゴリ名のような短い語では、この差が大きく出ます。2 文字の語は bigram が 1 個しかなく、1 文字の違いがスコアを大きく動かします。

実際に両方を動かして比べました(語は作例です)。

通称 → 正式名 difflib(移植前) string-similarity(移植後)
整体 → 整体院 0.800 0.667
歯科 → 歯科医院 0.667 0.500
薬局 → 調剤薬局 0.667 0.500
美容室 → 美容院 0.667 0.500
スーパー → スーパーマーケット 0.615 0.545
import difflib
difflib.SequenceMatcher(None, "歯科", "歯科医院").ratio()  # => 0.667
const ss = require("string-similarity");
ss.compareTwoStrings("歯科", "歯科医院");  // => 0.500

同じ閾値 0.6 の線を引くと、difflib では 5 ペアすべてが通り、移植後は「整体 → 整体院」以外の 4 ペアが落ちます。つまりこの移植は、コードの見た目上は「同じ閾値を維持した」のに、実質的にはマッチングを黙って厳しくしています。厳しくなった分はエイリアス CSV が拾うか、none として素通りします。

これを「間違い」とまでは言いません。落ちたペアはエイリアスに載せれば確実に拾えるので、fuzzy に頼る範囲を狭めたと解釈することもできます。ただ、それは結果的にそうなったのであって、検証して選んだ結果ではありませんでした。アルゴリズムを差し替えるとき、閾値は「同じ値」でも「同じ意味」ではない — 移植のたびに数語でいいから実測すべきだった、というのが反省です。

射影がどう機能したかを、運用ログで観測する

ここが本記事の核です。射影層は書いて終わりではなく、どの段でどれだけ救っているかを運用ログに出し続けています。

// src/utils/fuzzyMatch.ts  normalizeCategoryList() より
for (const item of items) {
  const resolved = normalizeValue(item.name);
  for (const { resolved: name, method } of resolved) {
    methodCounts[method] = (methodCounts[method] ?? 0) + 1;
    if (method !== "exact") {
      console.log(`normalize [${method}]: '${item.name}' -> '${name}'`);
    }
    // 重複スキップ(最初に出現したもの = 最高 weight を維持)
    if (seenNames.has(name)) continue;
    seenNames.add(name);
    normalized.push({ name, weight: item.weight });
  }
}

// 正規化サマリーログ
console.log(
  `normalize summary: ${JSON.stringify(methodCounts)} (total=${items.length} -> normalized=${normalized.length})`,
);
if ((methodCounts["none"] ?? 0) > 0) {
  console.warn(`${methodCounts["none"]}件が正規化不能(中分類名の混入の可能性あり)`);
}

設計判断は 3 つあります。

1. exact はログに出さない。 正常系(モデルが正式名称をそのまま出したケース)を出力すると、ログは「うまくいった記録」で埋まります。追いたいのは射影層が仕事をしたケースだけなので、alias / fuzzy / none の変換だけを 1 件ずつ記録します。件数の全体像はサマリー行の methodCounts が持ちます。

2. none の warn に、原因の仮説まで書く。 「N 件が正規化不能」で止めず、「中分類名の混入の可能性あり」と続けています。プロンプトには「小分類名から選定し、中分類名は使用しない」というルールがあり、none の急増はモデルがこのルールを破り始めた兆候として最も疑わしい。つまりこの warn は、プロンプト遵守度の検出器です。LLM の出力品質は宣言的にテストしにくいですが、射影層の統計は「モデルが語彙のルールをどれだけ守れているか」の代理指標になります。

3. 件数の増減を明示する。 1:N 展開があるので、入力 N 件と出力 M 件は一致しません。total=5 -> normalized=7 のように両方を出しておくと、「増えた・減った」をログだけで追えます。

正直に書いておくべき粗も 2 つあります。重複スキップのコメントは「最初に出現したもの=最高 weight」を前提にしていますが、モデルの出力順にその保証はありません(スキーマに順序制約は書けない、というのは前回書いたとおりです)。また 1:N 展開時に weight がそのまま両方へコピーされるのが妥当かも、検証されていません。どちらも実害が出た記録はまだありませんが、前提が明文化されないまま動いている箇所です。

受け止める側の反復 — 算術と欠損は、コードが持つ

射影層の下流にも、同じ原則の反復があります。カテゴリとは別のエンドポイントで、モデルに固定セグメント(2 軸の区分の全組み合わせ、全 14 件)の調整係数だけを出させ、確率分布に反映する処理です。

// セグメント補正ハンドラ(src/functions/ 配下)より
const adjustmentMap = new Map();
for (const adj of adjustedData) {
  adjustmentMap.set(segKey(adj), adj.coefficient || 1.0);    // 0 も 1.0 に倒す
}

const result = originalData.map((orig) => {
  const coefficient = adjustmentMap.get(segKey(orig)) ?? 1.0; // 未指定のみ 1.0
  const adjustedValue = orig.probability_value * coefficient;
  return { ...orig, adjusted_value: adjustedValue, coefficient };
});

const total = result.reduce((sum, item) => sum + item.adjusted_value, 0);
for (const item of result) {
  item.probability_value = total > 0
    ? item.adjusted_value / total
    : item.original_probability;   // 全滅時は元の分布へフォールバック
}

モデルが出すのは 0.3〜3.0 の係数だけで、掛け算も、合計を 1 に戻す再正規化も、コードがやります。total > 0 のガードは、モデルが全セグメントに 0 を返したときに元の分布へフォールバックする保険です。

短いコードですが、|| 1.0?? 1.0意図的に使い分けています。係数の取り込みは || なので、01.0 に倒れます — 係数 0 はそのセグメントを完全に消すことを意味し、それは調整ではなく事故とみなす判断です。一方、参照側は ?? なので、未指定のセグメントだけが 1.0(無調整)になります。1 文字の違いですが、「モデルの出力のどこまでを信じるか」の線がここに引かれています。

欠損の埋め方にも同じ思想があります。入力データに存在しないセグメントは 0 ではなく 0.01 で補完します。0 で埋めると、モデルがそのセグメントを「強調したい」と判断して係数 3.0 を返しても、0 × 3.0 = 0 で永久に浮上できません。全セグメントを必ず揃えるのは、下流の ETL が全組み合わせの存在を前提に JOIN するためです。

もう 1 つ、小さな教訓を添えます。このリポジトリの型定義ファイルにはセグメント区分の定数が export されていますが、grep すると参照が 1 件もありません。実際に使われているのはハンドラ内に別途定義された定数で、両者はたまたま一致しているだけです。定数を「用意する」ことと「守らせる」ことは別で、参照されない定義は整合の保証に何も寄与しません。

AI に仕事をさせない条件を、コードで先に判定する

最後がこの記事のハイライトです。セグメント補正の入力には対象の名称が含まれますが、「テスト1」のような名称では、モデルに調整の手がかりがありません。このケースはコードで先に判定します。

// セグメント補正ハンドラより
function isUncertainTarget(name) {
  const patterns = ["テスト", "test", "ダミー", "dummy", "サンプル", "sample", "", "tmp", "temp"];
  const lower = name.toLowerCase().trim();
  return (
    /^\d+$/.test(lower) ||   // 数字のみ
    lower.length <= 2 ||     // 2 文字以下
    patterns.some((p) => lower.includes(p))
  );
}

素朴なヒューリスティクスです。LLM 全盛の今なら「不明確かどうかもモデルに判定させればいい」と考えたくなりますが、この判定はその後のプロンプトの組み立てを分岐させるので、コードで確定させる必要があります。

if (isUncertain) {
  parts.push(
    "## 重要な指示",
    "**対象の名称が不明確または判別不可能なため、統計的妥当性を最優先してください。**",
    "- 全セグメントの coefficient を 1.0 に設定",
    "- 元データの分布を変更せず、そのまま保持すること",
  );
}
if (!isUncertain) {
  parts.push(
    "## 調整ルール",
    "- 対象の主要利用者層を 2.0~3.0 倍で強調、非該当層を 0.3~0.7 倍で抑制",
    // ...(対象タイプ別の指針が続く)
    "- 調整係数は 0.3~3.0 の範囲内",
  );
}

要点は、不明確な場合に「係数を全部 1.0 にせよ」と指示を足すだけでなく、通常時の調整ルールのブロックをプロンプトから丸ごと出さないことです。「主要利用者層を強調せよ」というルールと「何も調整するな」という指示を同時に見せると、モデルはどちらかを選ぶ、あるいは中途半端に両方に従おうとします。指示と誘惑を同時に見せて自制を求めるのではなく、選択肢そのものを消す。プロンプトを静的なテンプレートではなくコードで組み立てているのは、この出し分けのためです。

判定をモデルに任せなかった理由は、もう 1 つあります。この条件は「間違えたときの被害が非対称」です。不明確な名称を通常扱いしても出力が多少歪むだけですが、正常な名称を不明確扱いすると調整機能そのものが無効になります。この非対称性を確率的な判定に乗せる理由がありません。LLM に仕事をさせるかどうかの判定は、LLM より手前で終わらせる

まとめ — 射影で受け、ログで観測する

このシステムが LLM と数値パイプラインの境界に置いている道具立てを整理します。

仕事 置き場所 実装
表記の揺れを受け止める 射影層(4 段階の正規化) エイリアス(1:N、Excel で運用編集)→ 完全一致 → fuzzy(閾値は移植時に実測すべきだった)→ 一致なし
射影の質を知る 運用ログ exact は出さず、変換だけを記録。none は原因の仮説つきで warn し、プロンプト遵守度の検出器にする
算術 コード モデルには係数だけ出させ、掛け算・再正規化・欠損補完(0 ではなく 0.01)はコードが持つ
やらせない判断 コード(入口の判定) 条件を先に確定し、プロンプトから誘惑ごと消す

前回から通底しているのは「保証はコードに置く」という原則ですが、本記事の範囲はその語彙版です。スキーマの enum で語彙を閉じられるフィールドは前回の方法で済みます。閉じられない語彙 — 数百件のマスタから選ばせるようなフィールド — では、モデルの出力の表記まで制約することはできません。そこで出力側をさらに縛る方向へ進むのではなく、受け止める語彙空間を用意し、射影がどう機能したかを観測する方向に倒す。射影層の統計は、モデルがルールを守れているかを教えてくれる、宣言的テストの代わりでもあります。

LLM の出力は、丁寧に制約しても、最後は信頼境界を越えてくる外部入力です。外部入力に対してやるべきこと — 正規化し、検証し、観測し、フォールバックを持つ — を、LLM にもそのままやる。このシリーズで書いてきた 4 本は、結局すべてその変奏でした。


検証環境は Node.js 22 / Python 3(difflib は標準ライブラリ)/ string-similarity 4.x、2026 年 8 月時点のものです。スコアの実測値は本文中の作例ペアに対して実際に両実装を実行した結果です。なお、本文中のコード引用は、題材システムのドメイン固有の語彙・識別子を一般化した改変版です(設計の構造は実装のとおりです)。

参考リンク

0
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
0
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?