❄️

Cortex AISQLで住所の名寄せをやってみた

に公開

本記事は、Snowflake Advent Calendar 2025 の 1 日目です。
2025年10月1日に開催した第5回Snowflake中部ユーザー会で発表した内容に加筆再構成したものになります。
ですので、基本的に2025年9月末時点の情報に基づきます(一部最新情報反映あり)。AISQLは日々進化してくはずなので、最新情報は公式ドキュメントでご参照ください。

はじめに

名寄せについて

「名寄せ」・・それはデータ前処理の闇のひとつ。
名前、住所、品名、etc、同じものを表しているはずなのに、表記が安定しない。

そもそも、日本語のデータは英語と比べて表記が揺れる下地があります。

  • 表記文字としての、漢字・ひらがな・カタカナの併用
  • 表記数字としての、漢数字・アラビア数字の併用
  • コンピュータ上での、全角と半角の存在
  • 文化的・歴史的な背景(住所の丁目以下、苗字の異字など)
  • etc

人間が見れば一目で判断できるものでも、コンピュータで処理するとなると大変だったりします。曖昧な判断を、ルールベースの文字列処理ロジックに落とし込むのは大変だったためです。
・・・そう、これまでは!!

Cortex AISQL について

2025年6月のSnowflake Summit 2025で発表された、生成AIによるデータ処理をSQLで簡単に使うことが関数群です。2025年9月時点で11個がパブリックプレビューでしたが、本記事執筆の2025年11月時点ではその一部がGA(一般提供)になってるみたいです。(公式ドキュメントはこちら)

1 AI_COMPLETE 選択された LLM を使用して、指定された文字列または画像の補完を生成。生成的な AI タスクのほとんどにこの関数を使用。
2 AI_CLASSIFY テキストや画像をユーザー定義のカテゴリに分類。
3 AI_FILTER イメージやテキストの入力に対してTrueかFalseをし、 SELECT, WHERE, JOIN ... ON 句で結果をフィルターできる。
4 AI_AGG テキスト列を集約し、プロンプトに基づいて複数の行にわたる洞察を返す。
5 AI_SUMMARIZE_AGG テキスト列を集約し、複数行にわたるサマリーを返す。
6 AI_SIMILARITY 2つの入力間の埋め込み類似度を計算。
7 PARSE_DOCUMENT 内部または外部ステージからテキストを抽出 (OCR モードを使用)、又はテキストをレイアウト情報とともに抽出。
8 TRANSLATE サポートされている言語間のテキストを翻訳。
9 SENTIMENT テキストからセンチメントスコアを抽出。
10 EXTRACT_ANSWER 構造化されていないデータから質問の答えを抽出。
11 SUMMARIZE 指定したテキストの要約を返す。

これを見たときに真っ先に浮かんだのが名寄せでの利用でした。「人間だったら容易に判断できる」というものは、生成AIならなんとかなるんじゃないか、と。

実験:AISQLによる住所データの名寄せ

ということで、実験してみることにしました。
想定するユースケースは、「アンケート等で取得したデータを、自社の顧客マスタと紐づける」です。
一般的には、氏名・住所・電話番号・性別・生年月日など、複数の項目を使って名寄せするところですが、今回は最も表記揺れが激しい 住所 を単品で扱います。

実験用データの準備

今回の実験で使う住所データ100件を準備します。

「正解の住所」の生成

まず、表記の統一された住所を110件生成しました(100件ではなく。理由は後述)。
これも生成AI(Gemini)で作成しました。中部5県(愛知、岐阜、三重、静岡、長野)の架空の住所データです。「県」「市区」「町以下」の3カラムをセットで、以下3パターンからランダムに作ってもらいました。

  • ○県、○市(○区)、○町○丁目○番地○号
  • ○県、○市(○区)、○町○丁目○番地
  • ○県、○市(○区)、○町○番地

「表記揺れ住所」の追加

次に、正しい住所データの「町以下」に対し、実際にありそうな表記ゆれ2種をランダムで適用したデータを作成しました。こちらも110件になります。
(一般的に、「県」「市区」はコードで管理するため、「町以下」だけを対象としました)

  • 【加工1:丁目以下の表記揺れ】
    • A町B丁目C番地D号 (揺れ無し)
    • A町B丁目C番地D
    • A町B丁目C番D
    • A町B丁目C-D
    • A町B-C-D
  • 【加工2:漢数字化】
    • アラビア数字 (揺れ無し)
    • 漢数字

できたデータはこんな感じで110件です。以降「素材データ」と呼びます。

No 市区 正解の町以下 表記揺れの町以下 表記揺れ内容
1 愛知県 小牧市 小牧原町 6丁目 3番地 4号 小牧原町6-3-4 町以下揺れ
2 岐阜県 本巣市 北方町 4丁目 1番地 北方町4丁目1番 町以下揺れ
3 三重県 鈴鹿市 白子町 7丁目 22番地 白子町七丁目二十二番地 漢数字
4 静岡県 御殿場市 新橋町 2丁目 7番地 9号 新橋町二丁目七番地九 漢数字+町以下揺れ

実験で使うデータ2個の作成

素材データから、実験で使うデータ(table)を2つ作成しました。

  • 生データ( table名:address_raw ): アンケートで取得したデータに相当。素材データから100件を抽出、townカラムの値は「表記揺れの町以下」。
カラム名 意味 サンプル
no 素材データの連番 1
prefecture 愛知県
city 市区 小牧市
town 表記揺れの町以下 小牧原町6-3-4
  • マスタデータ( table名:address_master ): 社内の顧客マスタに相当。素材データから100件を抽出、townカラムの値は「正解の町以下」。
カラム名 意味 サンプル
no 素材データの連番 1
prefecture 愛知県
city 市区 小牧市
town 正解の町以下 小牧原町 6丁目 3番地 4号

生データとマスタデータは10件ずらしてあります(下表参照)。「正解が無いものに対して、ちゃんと『対応するものがない』と判定できるか」を確認する意図です。

生データ マスタデータ
生データのみ 10件 ---
両方にあり 90件 90件
マスタデータのみ --- 10件

実験1: AI_FILTERを使ってみる

実験1の内容

生データに対し、マスタデータを左外部結合します。
結合条件は、AISQLの一つ AI_FILTER を使った“生住所”と“マスタ住所”の一致判定です。

以下のSQLで、住所の一致判定を行いました。

/* AISQL名寄せ 実験1 */
select
  raw.no                                          as raw_no,
  raw.prefecture    || raw.city    || raw.town    as raw_address,
  master.no                                       as master_no,
  master.prefecture || master.city || master.town as master_address,
  raw.no = master.no                              as result
from
  address_raw raw  -- 生データ
  left outer join address_master master  -- マスタデータ
  on AI_FILTER(PROMPT( '"{0}" and "{1}" are the same japanese adress.',
                       raw.prefecture    || raw.city    || raw.town,
                       master.prefecture || master.city || master.town))
order by raw.no
;

JOINの結合条件であるON句に AI_FILTER を使っています。
ここでは AI_FILTER の引数はPROMPT関数にしてあって、 「“生住所”と“マスタ住所”は、同じ日本の住所です」 という英語のプロンプトを渡しています。(Snow Document に英語で書けとあるので英語にしてますが、“生住所”と“マスタ住所”の値は日本語のまま渡してます。ちなみに日本語プロンプトで書いても動きましたが、若干出力が違いました)
AI_FILTER は、プロンプトに対しtrueかfalseを返し、trueのときマスタデータの行は生データの行に結合されます。
生データと、ジョインされたマスタデータの カラムno が一致していたなら、正解の行がジョインされたことになります。それを カラムresult で確認しています。(tureなら正解)

実験1の結果

結果は100件中99件は正解、1件だけ不正解 になりました。

  • 生データのみの10件は、無事に「対応するマスタデータ無し」になりました。
  • 両方にある90件のうち89件は、正しいマスタデータがジョインされました
  • 両方にある90件のうち1件(No93)だけは、正解を含む5行のマスタデータにジョインされてしまいました。

どうもGeminiが、丁目以下が「6丁目3番地4号」というデータを5件作ってたみたいです。県と市区、あと町は異なるのですが、AI_FILTERは「同じ住所」と誤判断しちゃったみたいです。(なぜかNo93だけ誤判断してて、他4件は正常に判断できていました。謎です)
その結果、100行の生データに対して、 ジョイン結果は104行に膨らんで しまいました。

実験2: プロンプトを工夫してみる

実験2の内容

AI_FILTERが、県・市区・町名を意識して判定するように、プロンプトを工夫してみます。

/* AISQL名寄せ 実験2 */
select
<中略>
  on ai_filter(prompt( '"{0}" and "{1}" are the same prefecture and the same city and same town and same adress in japanese.', 
                       raw.prefecture    || raw.city    || raw.town,
                       master.prefecture || master.city || master.town))
order by raw.no
;

プロンプトを 「“生住所”と“マスタ住所”は、同一の県、同一の市区、同一の町、同一の日本の住所です」 に変えてみました。生成AIに、判定の時に県・市区・町名の一致の確認を意識させることを意図したものです。

実験2の結果

無事に、100件全てが正解 になりました。実験1で5件ジョインされてしまったNo93も、正解の1件だが紐づくようになりました。

ちゃんと、県・市区・町名を意識して判定するように改善できたようです。

実験3: AI_SIMILARITYを使って1:1でジョインしてみる

実験3の内容

実験2はうまくいっているように見えます。しかし、生成AIには再現性の保証がありません。結合条件に AI_FILTER を使う限り、実験1のように1:nで紐づいてしまう不安が残ります。
そこで、AISQLの一つ、AI_SIMILARITY を使って、1:1での紐づけ(JOINによるテーブルの膨らみの防止)の工夫をしてみます。

/* AISQL名寄せ 実験3 */
select
  raw.no                                          as raw_no,
  raw.prefecture    || raw.city    || raw.town    as raw_address,
  master.no                                       as master_no,
  master.prefecture || master.city || master.town as master_address,
  raw.no = master.no                              as result,
  /* 意味的類似度と、それによるRANKの計算 */
  AI_SIMILARITY( raw.prefecture || raw.city || raw.town, master.prefecture || master.city || master.town ) as SIMILARITY,
  RANK() OVER (PARTITION BY raw_address ORDER BY SIMILARITY DESC) as RANK,
from
  address_raw raw                        -- 生データ
  left outer join address_master master  -- マスタデータ
  on AI_FILTER(PROMPT( '"{0}" and "{1}" are the same japanese adress.',
                       raw.prefecture    || raw.city    || raw.town,
                       master.prefecture || master.city || master.town))
order by raw.no, RANK
;

AI_FILTERのプロンプトは、実験1のものに戻しました。したがって、No93は1:5で紐づく状況です。
そこで、AI_SIMILARITY を使って、“生住所”とジョインされた“マスタ住所”の 「意味的な類似度」 を計算します。さらに“生住所”毎に、紐づいた“マスタ住所”の類似度の順位をrankで計算します。

実験3の結果

No93の5行は“生住所”と“マスタ住所”の類似度が高い順に1位~5位が振られました。1位になったのは正解の1行で類似度は約0.98、その他の4行は約0.50~0.55と有意に低いスコアとなっています。
なお、No93以外は1行しか紐づかないので、一律で1位が振られています。

実験3のSQLをWITH句で処理して、その結果に対してRANKが1位のものだけを抽出すれば、生データの100行に対して一番類似度の高いマスタデータの1行が1:1で紐づくデータ を作ることができます。実験2と違って、この方法なら、ジョインの結果が100行のままであることが保証されるものになります。
(ただ、このやり方にはちょっと心配があります。後述の感想3をご参照)

感想

実際やってみた感想を4つ書きます。

感想1: JOINでAI_FILTERを使う場合の注意

この記事の基になった発表原稿を執筆した2025年9月の時点では、SnowDocumentのAI_FILTERのページには、以下の記述がありました。

AI_FILTER 関数を使用した JOIN 操作を実行する場合、 JOIN の各テーブルは500行を超えることはできません。

今回のようなユースケースを想定すると顧客マスタが500件上限ということになるので、発表時には「実務での利用は難しいレベルと思う。とはいえ、制限解除もAISQL破産の恐怖を感じる」とコメントしてました。
ところが、この記事執筆した2025年11月の時点で改めて確認すると、この「JOIN時の500行の制約」の記載はなくなっていました。そのため、後半の「AISQL破産の恐怖」について触れておきます。

結論から言うと、AI_FILTERによるJOINは、原理的には2つのテーブルの 直積(CROSS JOIN) になるはずです。したがって、大きいテーブルでやると相応のコンピューティングコストがかかる可能性が危惧されます。

そう思う理由は、マイクロパーティションで管理してるメタデータにまったく関係のない、生成AIによる意味的な世界での判定になるので、プルーニングを効かせることができず、全レコードの値を見てプロンプトを叩かないと結果がわからないはずだからです。
この見立て通りなら、1,000行×1,000行のジョインでは、恐ろしいことに1百万回プロンプト叩くことに・・。
また、クエリキャッシュも効かないようです。実験1~3ではクエリ叩くたびに同じ処理時間がかかりました(WarehouseサイズXSで、実験1~3とも10秒強)。これは、生成AIの出力はプロンプト叩かないとわからないので、クエリキャッシュを効かせてないんだろうと推察してます。

ただ、現在のSnow Documentには、以下のような記述があります。もしかすると、なんらか直積にならないような工夫をしてくれている可能性も感じます。このあたり、興味深いので機会があれば調べてみたいです。(今回は調べてません、すみません)

デフォルトでは、AI_FILTER には、対象となるクエリに関するパフォーマンスの最適化が組み込まれています。この最適化では、品質への影響を最小限に抑えながら、パフォーマンスを2~10倍高速化し、最大60%のトークン使用量を削減することができます。

この最適化は、クエリエンジンが適切なパターンを検出すると自動的にトリガーされます。他のクエリ最適化と同様に、Snowflakeは、この最適化がすべてのクエリに適用されることを保証しません。エンジンは、可能な場合に、適応型ルーティングとコンテキスト認識型書き換えを活用して、より効率的な AI オペレーションを実行します。

個人的には、AI_FILTERの引数で、以下が指定できるといいなと思いました。

  • パーティションとなるディメンションのカラム: 今回のユースケースで言うと、県・市区のカラムをパーティションに指定することで、直積を取る範囲を、同じ県・市区の範囲に留める、というイメージです。(ON句で and で条件に入れれば同じことできるのかもしれませんが)
  • タイムアウト時間: 一定以上の時間がかかったらクエリを打ち切ってくれる。うっかりすごい直積をの実行ちゃったときの保険に。

感想2: 生成AIの再現性のなさを実感

Cortex AISQLに限らず生成AI全般に言えることですが、処理の結果に再現性の保証がありません。同じデータに同じクエリを投げても、異なる結果が得られることがあります。(「生成AIはライブデモ殺し」と言われる所以ですね)

実際、中部UGの発表準備中の1ヶ月間に、2回ほど「実験1のクエリで正しい結果が出てしまう」という現象に遭遇しました。ある日突然正しく処理できるなって、すぐにまた間違うようになるというものです。Cortex AISQLのモデルに変更があったのか、同じモデルでの処理の揺らぎなのかは、定かではないですが・・。

再現性が保証されないのは生成AIの特性なので仕方がないので、ユースケースによってガードレール設置とか考えないと怖いなと感じました。

  • アドホック分析やワンショットのデータ前処理: 気楽に使える
  • データパイプラインやプロダクトに組み込む:  気を付けないと

ちなみに中部UG発表時は、ライブデモはやめといて、事前の実行結果をスライドに張る形態でやりました(苦笑)。

感想3: 関数によるAIの特性の違いに注意

実験3で、AI_FILTERとAI_SIMILARITYを組み合わせて使っています。自分でやっておいてなんですが、この2つは使ってる技術が違う ので、組み合わせて使うことが適切かは考えないといけません。

どういうことかと言うと、AI_SIMILARITYの類似度が高いことが、必ずしもAI_FILTERの判定の確度が高いことと相関するとは限らない、ということです。

AISQL 使われてる技術 ざっくり処理イメージ
AI_FILTER LLM(大規模言語モデル) プロンプトの後に続くのにもっともらしい文章を生成してみて、その内容が肯定的か否定的か
AI_SIMILARITY エンベッディング(意味ベクトル化) 2つの文章を意味的な多次元空間の座標に変換して、同じ方角にあれば類似度が高い

今回の題材の「住所」では、 AI_FILTERの判定とAI_SIMILARITYのスコアがそこそこ噛み合ったんですが、別で実験してたユースケースではぜんぜん噛み合わないこともありました。

感想4: シビアなテストケースでの検証の必要性

今回は、5県で100件という軽いデータだったので、よく似た近所の住所が併存するようなデータは存在していません。実務で使うこと考えるなら、「同じ市で町名が似ている」「番地や号が少しだけ違う」というときに間違った住所と紐づけをしないかの更なる検証が必要と思われます。
しかし一方で、実務では住所以外の情報も併用するでしょうから、住所はある程度の判定ができれば、役に立つ可能性は十分あると思います。

おわりに

実際に使ってみて、AISQLは、アドホック分析やワンショットのデータ前処理での「名寄せ」において、有用な道具になりえると感じました。期待した通り、日本の住所特有の表記の揺らぎを見事に吸収してくれたからです。
感想ではややネガティブなことを並べてしまいましたが、これは注意して使う必要のある道具だというだけです。

とにもかくにも、一昔前ならPythonUDFとかでゴリゴリ書かないといけなかった処理を、SQLで手軽に書けるのは、ホント便利だと思いました。

最後まで読んでいただき、ありがとうございました。
少し早いですが、みなさん良いお年を!

おまけ

住所以外も試してた中で、面白かったので紹介です。
思い付きで、郷土の英傑の改名前後を試してみたら・・・なんと名寄せできた!!
生成AIならではだと思います。実用性は皆無ですが・・・w

Snowflake Data Heroes

Discussion