Snowflakeで販促担当者の悩み「販促物をデータベース化する」を解決してみる。
はじめに〜カタログとの思い出〜
こんにちは
Fusicの岡山です。
「Snowflake1人アドカレチャレンジ」2日目です
前回:Snowflakeが速い理由 マイクロパーティションの中身を覗いてみる。
次回:何でもスプシで管理してしまう病を、Snowflakeで業務アプリを作って解決したい
カタログ、、、ってありますよね。
よくポストにはいっている、アレです。
以前、カタログに関わる仕事をしていたこともあり、届いたカタログを見て構成や紙質とかを会社ごとにチェックしてしまうことがしばしばあります。
そんなカタログですが、当然商品名、商品金額、商品番号といった情報が掲載されています。
販促側の目線で見たときに「去年の夏にどのページにどの商品をどれくらいの紙面で載せたっけ。。。」と過去のカタログを漁って、情報を取りまとめることが多々あり、それが非常に面倒でした。
「いやいや、制作時点でリスト化してるんだからそれ見たらいいじゃん」
と思いますよね。
当然リストはあるんですが、その時々によって保存フォーマットが違っていたり、他部署制作だと情報源がなかったりするわけです。
当時、「なんとかこれをデータベースにまとめられないかな、、」と頭を抱えていたんですが、そういえばSnowflakeのAISQLを使えばこれ解決できるのでは?と思ったので検証してみることにしました。
AISQL ~生成AIがSQL関数になった件について~
AISQL は、生成AIを SELECT 文の中で呼べる関数群。
2025年11月にGAになりました(出典: 2025-11-04 リリースノート)。
具体的な関数として以下があるようです。
| 関数 | 何をするか |
|---|---|
AI_COMPLETE |
汎用生成。テキストも画像も入力できる |
AI_CLASSIFY |
渡したラベルのどれか一つに分類する |
AI_EXTRACT |
ドキュメントから構造化データを抽出する |
AI_SENTIMENT |
感情を判定する |
AI_TRANSLATE |
翻訳する |
今回使うのは AI_EXTRACTです。そしてその前処理に AI_PARSE_DOCUMENTを使ってみようと思います。
AI_COMPLETEも行けそうな気がするんですが、今回はパッションのままにAI_EXTRACTと AI_PARSE_DOCUMENTを使ってみます。
AI_EXTRACT って何?
AI_EXTRACT は、テキストまたはPDFから「あらかじめ定義したスキーマの値」を抜き出す関数です。(出典: AI_EXTRACT 公式ドキュメント)。
単一の値を抜くだけでなく、配列(List)と表(Table)の抽出に対応しています。「1ページに複数商品が並ぶ」というカタログ構造に、そのままハマるのではないかとおもいます。
使い方はこんな感じ。
-- テキスト入力
AI_EXTRACT(text => <text>, responseFormat => <schema>)
-- ファイル入力(ステージ上のPDF等)
AI_EXTRACT(file => TO_FILE('@stage', 'file.pdf'), responseFormat => <schema>)
対応ファイル形式は PDF / DOCX / PNG / JPEG / HTML / PPT など。制限は1ドキュメント 最大125ページとのこと。
AI_PARSE_DOCUMENT って何
AI_PARSE_DOCUMENT は、PDFをテキストに変換する関数です(出典: AI_PARSE_DOCUMENT 公式ドキュメント)。
page_split => TRUE を渡すと、出力が ページごとの配列になります。各要素に index(0始まり)が付くので、ページ番号が取れるはず。
AI_PARSE_DOCUMENT(
TO_FILE('@stage', 'catalog.pdf'),
{'mode': 'LAYOUT', 'page_split': TRUE}
)
-- → {"pages": [{"index": 0, "content": "..."}, {"index": 1, ...}, ...]}
mode: 'LAYOUT' は表やカラムのレイアウト情報を残してテキスト化するモードとのこと。
複数カラムで商品が並ぶカタログに向いていますが、一般的なカタログは一概にカラムですべて担保できるのではないので、これがベストかは要検証です。
一旦使う関数の理解はできたので、実際に使って検証しましょう!
カタログ構造の整理
このデモで使うPDFは、3ページ・各ページ4商品(計12点)の通販カタログです。
Page 1: キッチン用品
┌──────────────────┬──────────────────┐
│ K-1001 │ K-1002 │
│ ステンレス保温マグ │ シリコン折りたたみ皿│
│ ¥2,480 │ ¥1,280 │
├──────────────────┼──────────────────┤
│ K-1003 │ K-1004 │
│ 計量スプーンセット │ 木製カッティングボード│
│ ¥980 │ ¥3,200 │
└──────────────────┴──────────────────┘
Page 2: アウトドア (4商品)
Page 3: 文具・オフィス (4商品)
抽出したい列は page / product_code / product_name / priceです。
Claude Opux4.8 MAXにしっかり作らせた結果がこれです。

。。。ラフ案ということで!!!
これが行けそうだったらもっとリッチなカタログを作って検証しましょう。そうしましょう。
では実際にSnowflakeので準備に取り掛かります。
ステップ0 — 準備
-- 自分が使っているロール名を確認する
SELECT CURRENT_ROLE(); -- 例: PUBLIC, SYSADMIN, ANALYST など
-- CORTEX_USER ロールを付与(accountadmin で一度だけ実行)
-- <YOUR_ROLE> を CURRENT_ROLE() で確認した名前に置き換える
USE ROLE accountadmin;
GRANT DATABASE ROLE snowflake.cortex_user TO ROLE <YOUR_ROLE>;
-- 付与後、自分のロールに戻す
USE ROLE <YOUR_ROLE>;
-- 操作対象のデータベースとスキーマを指定する
-- (ない場合は先に CREATE DATABASE demo_db; CREATE SCHEMA demo_db.public; で作成)
USE DATABASE sf_handson;
USE SCHEMA public;
-- ディレクトリ有効のステージを作成
CREATE OR REPLACE STAGE catalog_stage
DIRECTORY = (ENABLE = TRUE)
ENCRYPTION = (TYPE = 'SNOWFLAKE_SSE');
PDFをアップロードします。CLIでアップロードでもいいと思いますし、直接でもいいかと思います。
# SnowSQLまたはPUT文で
PUT file://./catalog_001.pdf @catalog_stage AUTO_COMPRESS=FALSE;
またはSnowsight上で、
カタログ→データベースエクスプローラ→上記で作成したステージをクリックして「+ファイル」からアップロードが可能です。
ステージにファイルが入ったら ALTER STAGE catalog_stage REFRESH; でディレクトリを更新します。
ステップ1 — ページ分割ができるか
まず AI_PARSE_DOCUMENT で3ページを行に展開します。
WITH parsed AS (
SELECT AI_PARSE_DOCUMENT(
TO_FILE('@catalog_stage', 'catalog_001.pdf'),
{'mode': 'LAYOUT', 'page_split': TRUE}
) AS doc
),
pages AS (
SELECT
p.value:index::int + 1 AS page, -- 0始まり → 1始まり
p.value:content::string AS page_text
FROM parsed,
LATERAL FLATTEN(input => parsed.doc:pages) p
)
SELECT page, LEFT(page_text, 80) AS preview FROM pages;
-- → 3行 (page=1,2,3) それぞれのテキストが確認できる

見た感じ良さそうです。
ステップ2 — ページごとに商品を抽出できるか
AI_EXTRACT に Table 抽出スキーマを渡します。column_ordering で列の順序を指定し、各列が配列で返ります。
WITH parsed AS (
SELECT AI_PARSE_DOCUMENT(
TO_FILE('@catalog_stage', 'catalog_001.pdf'),
{'mode': 'LAYOUT', 'page_split': TRUE}
) AS doc
),
pages AS (
SELECT
p.value:index::int + 1 AS page,
p.value:content::string AS page_text
FROM parsed, LATERAL FLATTEN(input => parsed.doc:pages) p
),
extracted AS (
SELECT
page,
AI_EXTRACT(
text => page_text,
responseFormat => {
'schema': {
'type': 'object',
'properties': {
'items': {
'type': 'object',
'column_ordering': ['product_code', 'product_name', 'price'],
'properties': {
'product_code': {
'type': 'array',
'description': '商品番号。K-1001のような形式'
},
'product_name': {
'type': 'array',
'description': '商品名'
},
'price': {
'type': 'array',
'description': '価格。¥記号や円表記を含む場合もある'
}
}
}
}
}
}
):response:items AS items
FROM pages
)
SELECT page, items FROM extracted;
-- → 3行。items列にJSON配列が入っているはず

いい感じですね!!
出力結果の配列はこんな感じに
{
"price": [
"2,480",
"1,280",
"980",
"3,200"
],
"product_code": [
"K-1001",
"K-1002",
"K-1003",
"K-1004"
],
"product_name": [
"ステンレス保温マグ 350ml",
"シリコン折りたたみ皿",
"計量スプーンセット 5本組",
"木製カッティングボード"
]
}
読み込んだページはこちらです。
シンプルなデザインですが、ちゃんと行けてますね。。。!

ステップ3 — 配列を行に展開してテーブル化(これが本番)
各ページの items は列ごとの配列です。
LATERAL FLATTEN で product_code 配列をインデックス付きで展開し、同じインデックスで他列を引きます。
CREATE OR REPLACE TABLE catalog_items AS
WITH parsed AS (
SELECT AI_PARSE_DOCUMENT(
TO_FILE('@catalog_stage', 'catalog_001.pdf'),
{'mode': 'LAYOUT', 'page_split': TRUE}
) AS doc
),
pages AS (
SELECT
p.value:index::int + 1 AS page,
p.value:content::string AS page_text
FROM parsed, LATERAL FLATTEN(input => parsed.doc:pages) p
),
extracted AS (
SELECT page,
AI_EXTRACT(
text => page_text,
responseFormat => {
'schema': {'type':'object','properties':{'items':{
'type': 'object',
'column_ordering': ['product_code','product_name','price'],
'properties': {
'product_code': {'type':'array','description':'商品番号(K-1001等)'},
'product_name': {'type':'array','description':'商品名'},
'price': {'type':'array','description':'価格'}
}
}}}
}
):response:items AS items
FROM pages
)
SELECT
page,
idx.index AS item_index,
items:product_code[idx.index]::string AS product_code,
items:product_name[idx.index]::string AS product_name,
items:price[idx.index]::string AS price_raw,
TRY_CAST(
REGEXP_REPLACE(items:price[idx.index]::string, '[^0-9]', '')
AS INT
) AS price
FROM extracted,
LATERAL FLATTEN(input => items:product_code) idx;
精度チェック
ステップ3で作成されたテーブルのレコードは以下のようなデータとなりました。
PDF3ページと比較すると商品番号、商品名、価格を正しく取り込めているようです。




まとめ
今回は簡単な検証ということで、かなりシンプルな構成のPDFをDB化してみましたが、
個人的にこの結果は嬉しい内容となりました。
3ページのPDFをCOMPUTE_WHをX-Smallで実行して数秒で処理できたので、
カタログなど冊子データであればものの数分でDB化ができるのではないかという希望が持てました。
こういった販促物をDBとして持つことで購買データとの分析や、販促物の一元管理などに利活用できるのではないかと思っています。
今度はもっと構造が複雑、画像がはいっているドキュメントで検証をしてみたいですね。
ありがとうございました。
Discussion