ai_decide() は 2026 年 10 月時点で Beta です。挙動や制約は変わりうるので、利用前に最新のドキュメントを確認してください。
はじめに
前回の記事で、音声の文字起こしに入った個人情報を、Databricks の ai_query()(SQL から LLM を呼ぶ関数)で見つけて消す、という構成を試しました。
このとき気になったのが、すべての行を LLM に通していることです。文字起こしの大半は個人情報を含まない雑談です。そこにも LLM を回すと、行数が増えたときに時間も費用もかさみます。
そんな中、Databricks から TypeSafe AI の Jev が使えるようになった、という発表がありました。
そこで今回は 「個人情報が入っているか」の判定だけを Jev 系の ai_decide() に任せ、入っていそうな行だけ ai_query() に回す構成を試しました。
Jev って何?
Jev は文章を書かずに判断だけを返すモデルです。「はい / いいえ」で答えられる判定項目を渡すと、「はい」の確率を 0〜1 で返します。以前、スタックチャンの会話で表情とツールの判断を Jev に任せたことがあります。
Databricks の SQL からは ai_decide() という関数で、Jev と同じ形式で使えます。
ai_decide(判定したい文章, 判定項目の JSON)
今回の使い方で嬉しいのは次の 2 点です。
- 元の文章を書き換えない。返ってくるのは確率だけなので、判定のついでに文章が変わることがありません。
-
判断と置換を分けられる。「消す必要があるか」は
ai_decide()、実際に見つけて消すのはai_query()の LLM、と役割を分けられます。
ai_decide() に渡す JSON のパラメーター(type / instructions / criteria)と戻り値の JSON の形式は、Jev の API(System One API)と同じです。ただし、中身が Jev 1.14 だとはドキュメントに書かれていません。
本当は Jev 1.14 本体も試したかったのですが、ワークスペースを用意するのが遅れたうえ、私の Databricks のアカウントはおそらくクレジットカードの引き落としがまだ済んでいないせいで、403 で使えませんでした😭 この記事の数字はすべて ai_decide() のものです。
構成と SQL
流れはこうです。
ai_decide() には、判定項目を JSON のパラメーターで渡します。type の noul は「はい / いいえ」で答える判定項目で、instructions に判定の内容、criteria に「はい」「いいえ」がそれぞれどういう状態かを書きます。
SELECT id,
ai_decide(text, '{
"has_pii": {"type": "noul",
"instructions": "この発話は音声の文字起こしで、誤変換を含む。特定の個人を識別できる情報(人名、生年月日、住所、電話番号、メールアドレス、会員番号、カード番号、マイナンバーなど)が、崩れた表記も含めて入っているか",
"criteria": {"true": "個人を識別できる情報が 1 つでも入っている",
"false": "社名・寺社名・地名・金額・日付・営業時間だけで、個人を識別できる情報は入っていない"}}
}') AS d
FROM utterances;
確率は d:response.answers.has_pii.probability で取り出せます。この確率がしきい値以上の行だけを ai_query() に回します。
試したデータ
1 行 1 発話の文字起こしを 40 行用意しました。値はすべて架空で、正解は手で付けています。
- 個人情報あり 16 行: 前回の記事で使った自己紹介(加茂鹿男さん)の Whisper の出力を文ごとに分けた 8 行と、崩れていない発話や読みで書かれた発話 8 行です。
- 個人情報なし 24 行: 雑談に加えて、社名・寺社名・金額・日付や、「電話番号を変えたら連絡しますね」のような紛らわしい発話を入れました。
個人情報ありの 16 行
| id | 種類 | 発話 |
|---|---|---|
| 1 | 人名 | 初めましてかもしかおうと申しますかもしかおです |
| 2 | 生年月日 | 1978年4月10日までの48歳でまれもすらちもならです |
| 3 | 住所 | 中所は7件7支映像市長4609番地しっかりを配置2丸産合室です |
| 7 | 電話番号 | 会社の直数点は074223456 期待は090101234567です |
| 8 | メール | メラルですはしかおうどっとかもアットマックイグザンプルドっと込むです |
| 10 | 会員番号 | 会員番号はNP2026000731です |
| 13 | カード番号 | 画面の脳年の12月に開催して仕方人使ってカードは1111111111111111111111111159話2028年3月です |
| 14 | マイナンバー | あと前日役書の前な場合聞かれて1 2 3 4 5 6 7 8 9 01 2と答えたら読みありなくていいですと言われました |
| 33 | 人名 | 担当の佐藤花子さんに折り返しをお願いしたいです |
| 34 | 電話番号 | 携帯は080の1111の2222です |
| 35 | 電話番号 | ぜろきゅうぜろのいちにいさんよんのごろくななはちに掛けてください |
| 36 | メール | メールはtaro.yamada@example.comでお願いします |
| 37 | 住所 | 住所は奈良県奈良市登大路町30番地の5です |
| 38 | 生年月日 | 生年月日は平成2年5月3日です |
| 39 | カード番号 | カード番号は4111 1111 1111 1111です |
| 40 | 人名 | やまだたろうですやまはやまかわのやまです |
SQL は SQL ウェアハウス(serverless)で流しています。
1 項目で判定した結果
上の 1 項目で 40 行を判定すると、崩れていない発話と崩れた発話で確率がはっきり分かれました。
| 崩れていない発話 | 確率 | 崩れた・読みで書かれた発話 | 確率 |
|---|---|---|---|
| 37 住所 | 0.95 | 1 人名 かもしかおう
|
0.07 |
| 39 カード番号 | 0.93 | 14 マイナンバー(空白区切り) | 0.08 |
| 38 生年月日(和暦) | 0.89 | 35 電話番号(ひらがな) | 0.09 |
| 34 携帯 | 0.83 | 8 メール(カタカナ) | 0.18 |
| 36 メール | 0.79 | 13 カード番号(28 桁) | 0.22 |
| 33 人名 | 0.67 | 3 住所(崩れ) | 0.24 |
崩れていない発話は 0.67〜0.97 で、拾えています。個人情報なしの雑談も 0.00〜0.03 がほとんどです。同じ SQL をもう 1 回流しても、確率はほぼ同じでした。
ところが、表の右側のように、文字起こしで崩れた発話は 0.07〜0.24 にとどまりました。取りこぼし(個人情報があるのに ai_query() に回らない行)を 0 にしようとすると、しきい値を 0.07 まで下げることになります。
| しきい値 | 取りこぼし |
ai_query() に回る個人情報なし |
ai_query() に回さずに済む行 |
|---|---|---|---|
| 0.5 | 8 | 1 | 31 / 40 |
| 0.2 | 4 | 3 | 25 / 40 |
| 0.07 | 0 | 9 | 15 / 40 |
崩れた文字起こしを拾うための工夫
工夫は 3 段階です。
工夫 1: 崩れ方の例を書く
has_pii の instructions に、文字起こしがどう崩れるかの例を書き足しました。
例: 名前がひらがなや別の漢字になる(かもしかおう)、数字が空白で区切られる(1 2 3 4)、
数字がひらがなで読まれる(ぜろきゅうぜろ)、メールアドレスがカタカナで読まれる(アットマーク、ドットコム)、
桁数が多すぎる番号(カード番号の聞き間違い)、住所の地名が別の漢字になる
崩れた発話は 0.4〜0.6 まで上がりました。ただ、ひらがなの電話番号(35)は 0.29 にとどまりました。
工夫 2: 種類ごとに判定項目を分ける
「個人情報が入っているか」を 1 項目で判定する代わりに、人名・生年月日・住所・電話番号・メール・番号類の 6 項目に分けました。スタックチャンのときも、健康の指標ごとに判定項目を分けていました。
{
"name": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、話し手や他人の氏名が入っているか。ひらがなや別の漢字に崩れていてもよい。社名や店名は含めない"},
"phone": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、電話番号そのものが入っているか。数字がひらがなで読まれていてもよい。『電話番号』という言葉だけなら含めない"},
"id_number": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、会員番号・クレジットカード番号・マイナンバーなど個人に紐づく番号が入っているか。数字が空白で区切られていたり桁数がおかしかったりしてもよい。注文番号という言葉だけなら含めない"}
}
(生年月日・住所・メールも同じ書き方なので省略しています)
ひらがなの電話番号(35)は 0.65 まで上がりました。今度は かもしかおう(1)が 0.1 のままです。
工夫 3: 両方を 1 回に並べて、一番高い確率を使う
工夫 1 と工夫 2 は、拾える発話が互いに違いました。そこで、工夫 1 の 1 項目と工夫 2 の 6 項目を 1 回の ai_decide() に並べ、7 項目のうち一番高い確率をその行の確率にしました。
SELECT id,
greatest(
d:response.answers.has_pii.probability::double,
d:response.answers.name.probability::double,
d:response.answers.birth.probability::double,
d:response.answers.address.probability::double,
d:response.answers.phone.probability::double,
d:response.answers.email.probability::double,
d:response.answers.id_number.probability::double
) AS p
FROM gate_both;
7 項目を並べても、各項目の確率は別々に渡したときとまったく同じ値でした。項目同士は影響し合わないようです。並べた分だけ遅くなることもなく、40 行で 6 秒前後でした。
| しきい値 | 取りこぼし |
ai_query() に回る個人情報なし |
ai_query() に回さずに済む行 |
|---|---|---|---|
| 0.3 | 0 | 6 | 18 / 40 |
| 0.4 | 0 | 2 | 22 / 40 |
| 0.5 | 1 | 1 | 24 / 40 |
しきい値 0.4 で取りこぼしが 0 になり、40 行中 22 行を ai_query() に回さずに済みました🎉
個人情報なしなのに ai_query() に回ったのは 2 行です。崩れた地名で住んでいる場所を話している発話(0.43)と、「電話番号を変えたら連絡しますね」(0.77)でした。
しきい値 0.4 は、この 40 行を見ながら決めた値です。別のデータでは取りこぼす可能性があります。実際に使うなら、正解付きのデータを別に用意して、そちらで取りこぼしを数えてから決めてください。
置換まで通して使ってみる
判定だけでは、まだ個人情報は消えていません。ai_decide() が返すのは「この行に入っていそうか」の確率で、行のどこに入っているかは分からないからです。見つけて置き換えるのは ai_query() の LLM に任せます。
使い方としてはこんな流れを想定しています。
- 通話の文字起こしが 1 行 1 発話でテーブルにたまっていく。
-
ai_decide()で、各行に「個人情報が入っている確率」の列を付ける。 - 確率が 0.4 以上の行だけ
ai_query()で個人情報を抜き出し、[PHONE]のようなラベルに置き換えてマスク済みのテーブルに書く。0.4 未満の行はそのまま書く。 - 分析する人はマスク済みのテーブルだけを見る。
2 と 3 を SQL にするとこうなります。
-- 2. 各行に「個人情報が入っている確率」の列を付ける
CREATE OR REPLACE TABLE utterances_flagged AS
SELECT id, text,
greatest(
d:response.answers.has_pii.probability::double,
d:response.answers.name.probability::double,
-- birth / address / phone / email / id_number も同じように並べる
d:response.answers.id_number.probability::double
) AS pii_prob
FROM (
SELECT *, ai_decide(text, '<工夫 3 の 7 項目の JSON>') AS d
FROM utterances
);
-- 3. 0.4 以上の行だけ LLM で抜き出して置換し、0.4 未満の行はそのまま入れる
CREATE OR REPLACE TABLE utterances_masked AS
WITH extracted AS (
SELECT id, text,
from_json(ai_query(
'databricks-meta-llama-3-3-70b-instruct',
concat('次の日本語の文字起こしから個人情報を抽出してください。……', text),
responseFormat => '<抜き出す形の JSON スキーマ>'
), 'STRUCT<entities: ARRAY<STRUCT<text: STRING, label: STRING>>>').entities AS entities
FROM utterances_flagged
WHERE pii_prob >= 0.4
)
SELECT id,
aggregate(filter(entities, e -> e.text != ''), text,
(acc, e) -> replace(acc, e.text, concat('[', e.label, ']'))) AS masked_text,
exists(entities, e -> e.text != '' AND instr(text, e.text) = 0) AS needs_review
FROM extracted
UNION ALL
SELECT id, text, false
FROM utterances_flagged
WHERE pii_prob < 0.4;
ai_query() には前回の記事と同じく「原文の該当箇所をそのまま抜き出して」と頼み、置換は SQL の replace で行います。LLM に文章を書き直させないので、個人情報以外の部分はそのまま残ります。
省略なしの SQL
-- 2. ai_decide で各行に「個人情報が入っている確率」の列を付ける
CREATE OR REPLACE TABLE utterances_flagged AS
SELECT id, text,
greatest(
d:response.answers.has_pii.probability::double,
d:response.answers.name.probability::double,
d:response.answers.birth.probability::double,
d:response.answers.address.probability::double,
d:response.answers.phone.probability::double,
d:response.answers.email.probability::double,
d:response.answers.id_number.probability::double
) AS pii_prob
FROM (
SELECT *, ai_decide(text, '{
"has_pii": {"type": "noul",
"instructions": "この発話は音声の文字起こしで、誤変換が多い。特定の個人を識別できる情報が入っているか。崩れた表記でも、個人情報を言っている途中なら入っているとみなす。例: 名前がひらがなや別の漢字になる(かもしかおう)、数字が空白で区切られる(1 2 3 4)、数字がひらがなで読まれる(ぜろきゅうぜろ)、メールアドレスがカタカナで読まれる(アットマーク、ドットコム)、桁数が多すぎる番号(カード番号の聞き間違い)、住所の地名が別の漢字になる",
"criteria": {"true": "個人を識別できる情報(人名、生年月日、住所、電話番号、メールアドレス、会員番号、カード番号、マイナンバーなど)が崩れた表記も含めて 1 つでも入っている", "false": "社名・寺社名・地名・金額・日付・営業時間だけで、個人を識別できる情報は入っていない"}},
"name": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、話し手や他人の氏名が入っているか。ひらがなや別の漢字に崩れていてもよい。社名や店名は含めない"},
"birth": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、誰かの生年月日が入っているか。行事や予定の日付は含めない"},
"address": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、個人の住所(番地や部屋番号を含む)が入っているか。観光地や寺社の名前だけなら含めない"},
"phone": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、電話番号そのものが入っているか。数字がひらがなで読まれていてもよい。『電話番号』という言葉だけなら含めない"},
"email": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、メールアドレスそのものが入っているか。アットマークやドットコムがカタカナで読まれていてもよい"},
"id_number": {"type": "noul", "instructions": "この発話(音声の文字起こし、誤変換あり)に、会員番号・クレジットカード番号・マイナンバーなど個人に紐づく番号が入っているか。数字が空白で区切られていたり桁数がおかしかったりしてもよい。注文番号という言葉だけなら含めない"}
}') AS d
FROM utterances
);
-- 3. 確率が 0.4 以上の行だけ ai_query で個人情報を抜き出して置換し、0.4 未満の行はそのまま入れる
-- 抜き出した文字列が原文に無い(LLM が書き換えて返した)行は needs_review を true にする
CREATE OR REPLACE TABLE utterances_masked AS
WITH to_llm AS (
SELECT id, text FROM utterances_flagged WHERE pii_prob >= 0.4
),
extracted AS (
SELECT id, text,
from_json(ai_query(
'databricks-meta-llama-3-3-70b-instruct',
concat('次の日本語の文字起こしから個人情報を抽出してください。',
'文字起こしの誤りで表記が崩れていても、文脈から個人情報と判断できるものは含めてください。',
'原文の該当箇所を text にそのまま書き、書き換えないでください。個人情報が無ければ entities は空にしてください。\n\n', text),
responseFormat => '{"type":"json_schema","json_schema":{"name":"pii_entities","schema":{"type":"object","properties":{"entities":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string"},"label":{"type":"string","enum":["PERSON","DATE_OF_BIRTH","PHONE","ADDRESS","EMAIL","MEMBER_ID","CARD","MY_NUMBER"]}},"required":["text","label"]}}},"required":["entities"]},"strict":true}}'
), 'STRUCT<entities: ARRAY<STRUCT<text: STRING, label: STRING>>>').entities AS entities
FROM to_llm
)
SELECT id, aggregate(filter(entities, e -> e.text != ''), text, (acc, e) -> replace(acc, e.text, concat('[', e.label, ']'))) AS masked_text, entities,
exists(entities, e -> e.text != '' AND instr(text, e.text) = 0) AS needs_review,
true AS checked_by_llm
FROM extracted
UNION ALL
SELECT id, text AS masked_text, CAST(NULL AS ARRAY<STRUCT<text: STRING, label: STRING>>) AS entities, false AS needs_review, false AS checked_by_llm FROM utterances_flagged WHERE pii_prob < 0.4;
置換した結果
40 行を流すと、たとえばこう置き換わりました。
| id | 元の発話 | マスク後 |
|---|---|---|
| 7 | 会社の直数点は074223456 期待は090101234567です |
会社の直数点は[PHONE] 期待は[PHONE]です |
| 33 | 担当の佐藤花子さんに折り返しをお願いしたいです |
担当の[PERSON]さんに折り返しをお願いしたいです |
| 35 | ぜろきゅうぜろのいちにいさんよんのごろくななはちに掛けてください |
[PHONE]に掛けてください |
| 37 | 住所は奈良県奈良市登大路町30番地の5です |
住所は[ADDRESS]です |
| 17 | 東大寺の大仏殿は朝8時から開いています |
(ai_query() に回らず、そのまま) |
ひらがなで読まれた電話番号(35)も、まるごと [PHONE] になっています。
全行を ai_query に回したときと比べて
同じ置換を、ai_decide() で判定せずに全 40 行で流したものと比べました。LLM の結果は実行ごとに少しぶれるので、4 回流しています。
ai_decide() で先に判定 |
全行を ai_query()
|
|
|---|---|---|
ai_query() に回した行 |
18 | 40 |
| 個人情報ありなのに何も置換されなかった行 | 4 | 4 |
| 個人情報なしなのに置換された行 | 1 | 3〜4 |
置換されなかった 4 行はどちらも同じでした。崩れた住所(3)・カタカナのメール(8)・空白区切りのマイナンバー(14)は、ai_query() 自体が見つけられていません。崩れた文字起こしに弱いのは ai_query() も同じで、ai_decide() で先に判定したせいで増えた取りこぼしはありませんでした。
一方で、余計な置換は減りました。全行に回すと、ai_query() は「1月24日」や「2028年3月」を生年月日として置き換えてしまいます。こうした行は ai_decide() の確率が 0.3 前後で、そもそも ai_query() に回りません。
LLM が書き換えて返すと置換できない
もう 1 行の取りこぼし、携帯は080の1111の2222です(34)は事情が違いました。ai_query() は見つけていたのですが、080-1111-2222 と整えて返していたのです。原文にその文字列はないので、replace で置き換えられませんでした。
そこで上の SQL では、抜き出した文字列が原文にない行に needs_review を立てています。この 40 行では 34 だけが true になりました。この列で、人が見直す行を絞り込めます。
1000 行に増やすと
40 行だと、判定してから回すほう(ai_decide() 5.0 秒 + ai_query() 7.2 秒)が、全行に回すほう(5.2 秒)より遅いくらいでした。SQL ウェアハウスが行をまとめて並列に処理するので、この行数では LLM に回す行を減らしても待ち時間が縮みません。
そこで、40 行を 25 回複製した 1000 行でも流しました。
ai_decide() で先に判定 |
全行を ai_query()
|
|
|---|---|---|
ai_query() に回した行 |
450 | 1000 |
| 時間 | 75.8 秒(判定 15.7 秒 + LLM 60.1 秒) | 94.6 秒 |
| 個人情報なし 600 行のうち置換された行 | 25 | 85 |
1000 行なら、判定を足しても全体で 2 割ほど速くなりました。LLM に回す行が半分以下になるので、行数が増えるほど差は大きくなるはずです。ai_query() の費用も呼んだ行数に応じてかかるので、こちらも半分以下で済みます。
使うときの注意
-
ai_decide()は Beta です。モデルは予告なく変わりうるとドキュメントにあり、確率の出方が変わればしきい値も見直しになります。 - Databricks SQL Classic では使えません。リージョンの制約もあります。
-
ai_decide()の料金は、2026 年 10 月時点で料金ページに載っていません。今回の課金明細(system.billing.usage)にもai_decide()の分は出ていませんでしたが、Beta の間は無料とも書かれていないので、使う前に確認してください。 - しきい値未満の行は LLM で見直されないまま後ろに流れます。取りこぼしをどこまで許すかで、しきい値を決める必要があります。
まとめ
-
ai_decide()は、判定したい文章と「はい / いいえ」の判定項目を渡すと確率だけを返すので、元の文章を書き換えずに「LLM に回す必要があるか」を決められました。 - 文字起こしの崩れには弱いものの、崩れ方の例を書いた 1 項目と種類ごとの 6 項目を並べて最大値を取ると、この 40 行では取りこぼし 0 のまま半分強を LLM に回さずに済みました。
- 置換まで通すと、取りこぼしは全行を LLM に回したときと変わらず、余計な置換は減りました。
- 時間は、40 行では縮みませんでした。1000 行では LLM に回す行が半分以下になり、全体で 2 割ほど短くなりました。費用も、LLM に回す行が減る分だけ安くなるはずです。
- 判断は判断用のモデル、置換は LLM、という分け方は、スタックチャンのときと同じ形で Databricks の SQL でも使えました。
追伸
今回は Free Edition ではない検証用のアカウントで試したのですが、費用の大半は AI 関数ではなく、SQL ウェアハウスの費用でした。。。最小の 2X-Small でも、高い勉強代でした😢
皆さんは serverless のジョブを使って検証しましょうね。単価はウェアハウスの半分で、動いた分だけの課金なので。
参考
