bigframes の ai.if_ は本当に安い?pandas から BigQuery AI.IF のコスト最適化を検証

検証環境
  • bigframes 2.52.0
  • pandas 3.1.1
  • google-cloud-bigquery 3.47.0
  • Python 3.14.4
  • Google Cloud SDK 591.0.0
  • BigQuery(US マルチリージョン、2026-11-07 実行)
  • gemini-3.1-flash-lite / text-embedding-005
  • GitHub Actions(ubuntu-latest)

BigQuery DataFrames(bigframes)の .bigquery アクセサを使うと、pandas の DataFrame から BigQuery の AI 関数を直接呼べる。公式ブログいわく、bigframes の ai.if_() は既定でコスト最適化モードになるらしい。蒸留したローカルモデルが大半の行をさばいて、残りだけを Gemini に回す仕組みや。ほんまかいな、と思って Hacker News の見出し 4,000 行で試してみた。

結論: pandas から既定の ai.if_ を呼んでもコスト最適化は効かない

bigframes 2.52.0 の df.bigquery.ai.if_() を既定のまま呼んだら、4,000 行全部が LLM に回った。全行を LLM で判定する maximize_quality と比べても、結果は 4,000 行すべて同じやった。

原因は単純で、SQL の AI.IF の最適化モードには入力の埋め込みが要るのに、bigframes 2.52.0 の ai.if_ にはそれを渡す embeddings 引数が無い。同じアクセサの df.bigquery.sql_scalar() で AI.IF を手書きして、AI.EMBED の結果を渡したら、ちゃんと効いた。LLM に回る行は 3 分の 1 まで減って、時間も費用も半分以下。全行 LLM との判定のずれは 2% ちょっとやった。

ただ最適化モードはまだプレビューで、1,000 行ではエラーで止まった。せやから自分は、数千行を超える分類だけ sql_scalar で埋め込みを渡して、1,000 行くらいなら最初から maximize_quality で投げることにした。

検証環境

GitHub Actions 上の pandas から bigframes 経由で BigQuery の AI.IF を呼び、Gemini と埋め込みモデルを使う検証構成
GitHub Actions 上の pandas から bigframes 経由で BigQuery の AI.IF を呼び、Gemini と埋め込みモデルを使う検証構成
項目 内容
実行場所 GitHub Actions の ubuntu-latest から検証用 GCP プロジェクトへ
Python / ライブラリ Python 3.14.4、bigframes 2.52.0、pandas 3.1.1、google-cloud-bigquery 3.47.0
BigQuery US マルチリージョン。公開データ bigquery-public-data が US にあるため
LLM gemini-3.1-flash-lite。A・B は endpoint で指定
埋め込み text-embedding-005、task_type => 'CLASSIFICATION'
認証 接続は作らず、エンドユーザー認証。権限は手順 1 の後に書く
データ bigquery-public-data.hacker_news.full の 2024 年のストーリー見出し 4,000 件。平均 51.3 文字
判定文 「この Hacker News の見出しは AI・機械学習・大規模言語モデルの話か」
実行日 2026-11-07

比べたのは次の 4 パターン。全行 LLM の B を物差しにしてる。

パターン 呼び方 行数
A df.bigquery.ai.if_(...)。optimization_mode は指定しない 4,000
B df.bigquery.ai.if_(..., optimization_mode="maximize_quality")。全行 LLM 4,000
C df.bigquery.sql_scalar(...) で AI.IF に embeddings => AI.EMBED(...) と MINIMIZE_COST を渡す 4,000
D C と同じ書き方で 1,000 行 1,000

先に断っとくと、最適化モード、つまり MINIMIZE_COST と embeddings 引数は、検証した 2026-11-07 の時点でまだ プレビュー や。Pre-GA Offerings Terms がかかる。自動埋め込み生成は 2026-06-17 に、US マルチリージョンの gemini-3.1-flash-lite は 2026-08-10 に、リリースノートで GA になってる。AI.IF 本体と bigframes の .bigquery アクセサがどの段階なのかは、書いてある所を見つけられへんかった。

手順

1. bigframes を入れてオプションを決める

pip install bigframes==2.52.0
import bigframes.pandas as bpd

bpd.options.bigquery.project = "<PROJECT_ID>"
bpd.options.bigquery.location = "US"
bpd.options.compute.maximum_bytes_billed = 20 * 10**9   # BigQuery のスキャン課金の上限(Gemini・埋め込みの費用は止まらない)
bpd.options.compute.extra_query_labels["purpose"] = "tecblog-lab"

session = bpd.get_global_session()
# ジョブ統計(genAiStats)を必ず取れるよう、ジョブなしの短縮実行を使わない
session.bqclient.default_job_creation_mode = "JOB_CREATION_REQUIRED"

権限は横着した。使い捨ての検証用プロジェクトを作って、そのプロジェクトだけで編集者相当のサービスアカウントを使ってる。本番でこれをやったらあかん。公式ドキュメントでは、接続なしで AI 関数を呼ぶのに要るのは roles/bigquery.jobUser と roles/aiplatform.user、それに読み書きするテーブルやデータセットの権限。この最小構成で通るかは、まだ確かめてへん。

2. 公開データから 4,000 行を pandas に読み込む

いったん自分のデータセットにテーブルを作って、to_dataframe() で普通の pandas の DataFrame にした。

CREATE TABLE `<PROJECT_ID>.<DATASET>.hn_titles` AS
SELECT
  ROW_NUMBER() OVER (ORDER BY FARM_FINGERPRINT(CAST(id AS STRING))) AS rn,
  id,
  title
FROM `bigquery-public-data.hacker_news.full`
WHERE type = 'story'
  AND title IS NOT NULL
  AND deleted IS NOT TRUE
  AND dead IS NOT TRUE
  AND EXTRACT(YEAR FROM timestamp) = 2024
ORDER BY FARM_FINGERPRINT(CAST(id AS STRING))
LIMIT 4000
from google.cloud import bigquery

client = bigquery.Client(project="<PROJECT_ID>", location="US")
df = client.query(
    "SELECT id, title FROM `<PROJECT_ID>.<DATASET>.hn_titles` ORDER BY rn"
).to_dataframe()
df["title"] = df["title"].astype("string")

3. 3 行で試運転する

EP = "gemini-3.1-flash-lite"
PREFIX = (
    "Is the following Hacker News story title about AI, machine learning, "
    "or large language models? Title: "
)

df3 = df.head(3)
trial = df3.bigquery.ai.if_((PREFIX, df3["title"]), endpoint=EP, optimization_mode="maximize_quality")

接続を作らんでも普通に動いて、pandas の boolean の Series が返ってきた。0.69 秒は速すぎるやろ、と思ったらジョブ統計に cacheHit: true。クエリキャッシュに当たっただけで、推論にかかった時間とはちゃう。

                                                    title  is_ai
0                Climate Change: Incoming Sunlight (2009)  False
1  Sampling from a Normal (Gaussian) Distribution on GPUs  False
2                    The ethics of advanced AI assistants   True
trial_elapsed_seconds=0.69
trial_dtype=boolean

4. A と B: ai.if_ を既定と maximize_quality で呼ぶ

# A: pandas の既定(optimization_mode を指定しない)
a = df.bigquery.ai.if_((PREFIX, df["title"]), endpoint=EP)

# B: 全行 LLM
b = df.bigquery.ai.if_((PREFIX, df["title"]), endpoint=EP, optimization_mode="maximize_quality")

5. C: sql_scalar で埋め込みを渡して MINIMIZE_COST

sql_scalar のテンプレートでは、{title} が DataFrame の列に置き換わる。気をつけるのは、関数名を AI.`IF` と書くことと、endpoint を書かへんことの 2 つ。どっちも 1 回目にやらかしたとこで、詳しくはハマりどころに書いた。

import pandas as pd

template = (
    "AI.`IF`(('Is the following Hacker News story title about AI, machine learning, "
    "or large language models? Title: ', {title}), "
    "embeddings => AI.EMBED({title}, endpoint => 'text-embedding-005', task_type => 'CLASSIFICATION').result, "
    "optimization_mode => 'MINIMIZE_COST')"
)
c = df.bigquery.sql_scalar(template, output_dtype=pd.BooleanDtype())

6. ジョブ統計で「何行が LLM に回ったか」を見る

AI.IF を含むクエリジョブには statistics.query.genAiStats.functionStats[] が付く。見るのは numProcessedRows と costOptimizationStats.numCostOptimizedRows の 2 つ。全部で何行さばいて、そのうち何行を蒸留モデルが引き受けたかが分かる。ジョブ ID は bigframes の実行履歴から拾って、get_job() で読んだ。

hist = session.execution_history().to_dataframe()
for job_id in hist["job_id"].dropna():
    job = client.get_job(job_id, location="US")
    gen = job._properties["statistics"].get("query", {}).get("genAiStats")
    print(job_id, gen)

LLM に回った行数は numProcessedRows − numCostOptimizedRows − numFailedRows で出してる。

結果: bigframes ai.if_ の既定と最適化モードの比較

4,000 行の比較表

項目 A: ai.if_ 既定 B: maximize_quality C: sql_scalar + embeddings
numProcessedRows 4,000 4,000 4,000
costOptimizationStats 出ない 出ない あり
numCostOptimizedRows 0 0 2,650
LLM に回った行 4,000 4,000 1,350(33.75%)
numCacheHitRows 0 0 0
Python 側の所要時間 739.53 秒 771.67 秒 274.19 秒
ジョブの実行時間 733.83 秒 762.86 秒 266.81 秒
totalSlotMs 3,636,038 10,452,184 2,150,366
True と判定した行 645 645 655
B との一致率 100% ― 97.75%
推定費用 約 20.6 円 約 20.6 円 約 7.8 円

計測は各 1 回きりで、C → B → A → D の順に続けて流した。ウォームアップも無し。同じ全行 LLM やのに、A と B でスロット時間が約 3 倍も違う。ここは正直、理由がよう分からん。時間とスロットは話半分で見といて。オンデマンド課金ならスロット時間は料金に関係ないけど、Editions で予約スロットを使うてる人には、C で減ったスロットのぶんも効いてくる。

計測 C で AI.IF の MINIMIZE_COST が 4,000 行のうち 2,650 行を蒸留モデル、1,350 行を Gemini で判定した内訳
計測 C で AI.IF の MINIMIZE_COST が 4,000 行のうち 2,650 行を蒸留モデル、1,350 行を Gemini で判定した内訳

A: 既定の ai.if_ は全行 LLM やった

今回いちばん言いたいのはここ。公式ブログには既定が minimize_cost と書いてあるのに、A のジョブ統計は B とまったく同じ形で、costOptimizationStats がそもそも無い。

// A(ai.if_ 既定)の genAiStats
{"errorStats": {}, "functionStats": [{"functionName": "AI.IF", "prompt": "Is the following Hac", "numProcessedRows": "4000", "errorStats": {}, "cacheStats": {"numCacheHitRows": "0"}}]}

// C(sql_scalar + embeddings)の genAiStats
{"errorStats": {}, "functionStats": [{"functionName": "AI.IF", "prompt": "Is the following Hac", "numProcessedRows": "4000", "errorStats": {}, "costOptimizationStats": {"numCostOptimizedRows": "2650", "message": "Cost Optimization successfully applied."}, "cacheStats": {"numCacheHitRows": "0"}}]}

bigframes が投げた SQL を見たら、A は optimization_mode の引数ごと省いてた。

AI.IF(
  prompt => STRUCT(
    'Is the following Hacker News story title about AI, machine learning, or large language models? Title: ',
    `title`
  ),
  endpoint => 'gemini-3.1-flash-lite'
)

SQL の AI.IF だけ見たら、optimization_mode の既定はちゃんと MINIMIZE_COST なんよ。落とし穴は公式リファレンスの一文で、embeddings も自動埋め込み生成も無かったら全行をふつうの LLM 推論で処理する、と書いてある。

で、bigframes 2.52.0 の ai.if_ が受け取るのは prompt, connection_id, endpoint, optimization_mode, max_error_ratio だけ。embeddings の口がそもそも無い。pandas の DataFrame はセッションの一時テーブルに読み込まれるから、自動埋め込み生成も付かへん。既定は最適化モードやのに、渡す埋め込みが無くて空振りしてた、いうことや。

optimization_mode="minimize_cost" を明示する呼び方は試してへん。ソースを見る限り埋め込みを渡す口が無いから、明示しても全行 LLM やと思う。A の判定は B と 4,000 行ぜんぶ一致してて、やり直す前の 1 回目の実行でも同じやった。その 1 回目は C と D がハマりどころの 2 つで落ちたから、数字には使ってへん。

C: 埋め込みを渡すと LLM 行は 3 分の 1、時間も約 3 分の 1

C では Cost Optimization successfully applied. が返ってきた。4,000 行のうち 2,650 行を蒸留モデルがさばいて、Gemini に回ったのは 1,350 行。13 分近くかかった B に対して、C は 4 分半ほどで終わった。

B と食い違ったのは 90 行で、一致率は 97.75%。内訳はこう。

C が True C が False
B が True 605 40
B が False 50 3,305

食い違った見出しを disagreements.csv からいくつか抜き出した。

見出し B C
Phi-3: Redefining what’s possible with SLMs True False
A faster, more flexible byte-pair tokenizer True False
Open Sora True False
Sampling from a Normal (Gaussian) Distribution on GPUs False True
How to Make Yourself into a Learning Machine False True
Autonomous Quantum Error Correction of Gottesman-Kitaev-Preskill States False True

C は「Phi-3」や「Open Sora」みたいに、名前を知らんと判断できへん見出しを落としてる。逆に「Learning Machine」は、単語につられて拾ったっぽい。Phi-3 を「AI の話ちゃう」と判定されたら、いやいや、と言いたくなる。

とはいえ B かて正解とは限らんから、97.75% は B を正解にした場合の数字や。ざっくりした集計や前処理のふるい分けなら、このくらいのずれは自分は気にならん。

D: 1,000 行では最適化モードがエラーで止まる

1,000 行に減らした D は、エラーで止まった。

Fail to apply cost optimizations because the number of rows to process is too small. Cost optimizations target large-scale datasets containing thousands of rows. Requires at least 1,300 rows (found 1,000).

ジョブ統計の costOptimizationStats.message にはこう出てた。サポートのメールアドレスは伏せてある。

Cost optimization was not applied because the data size is too small (requires at least a few thousand rows). To proceed, increase the data size to at least a few thousand rows, or contact support at ***@***. To disable cost optimization, remove the `embeddings` argument from the function call. If cost optimization is implicitly enabled through the generated embedding column, set the `optimization_mode` argument to `MAXIMIZE_QUALITY` to disable it.

これが地味に痛い。全行 LLM に切り替えてくれるんやなくて、クエリごとコケる。しかも失敗したジョブの numProcessedRows は 1,000 で、numCostOptimizedRows は 0。1 行も返ってこんのに、1,000 行ぶん LLM で処理したことになってる。

下限の行数も資料ごとに違う。下調べで読んだ公式ドキュメントは約 3,000 行、ジョブ統計のメッセージは「a few thousand rows」、エラー文は「at least 1,300 rows」。どれを信じたらええねん。1,300〜3,000 行の間は試してへんから、自分は余裕を見て数千行からにしとく。

ハマりどころ

embeddings と endpoint は同時に指定できない

1 回目の実行では、C にも endpoint => 'gemini-3.1-flash-lite' を付けてた。そしたらこれで止まった。

The 'endpoint' argument of ai.if is not supported when cost optimization is enabled via the 'embeddings' argument.

最適化モードでは、LLM のモデルは BigQuery が勝手に選ぶ。C の 1,350 行が何のモデルで判定されたかは、ログを見ても出てこんかった。

sql_scalar で AI.IF と書くと解析エラーになる

sql_scalar は、bigframes に同梱の sqlglot でテンプレートを解析してから SQL を組み立てる。1 回目に AI.IF(...) とそのまま書いたら、BigQuery に届く前にこれで止まった。

ParseError: Expecting ). Line 1, Col: 139.

エラーの位置からすると、AI.IF を条件式の IF(cond, a, b) と読んでるっぽい。そら IF 違いや。関数名を AI.`IF` とバッククォートで囲んだらあっさり通って、2 回目は普通に動いた。

失敗すると bigframes が同じクエリを送り直して費用が重なる

D では、BigQuery のエラーを受けて bigframes がこんな警告を出してた。

UserWarning: Compiler ID 8bb6cda6da8a: Exception on sqlglot. Falling back to ibis.
Details: 400 GET ...: Fail to apply cost optimizations because the number of rows to process is too small. ...

悪いのは BigQuery 側の行数不足。ところが bigframes は警告の文面どおり sqlglot の失敗として扱って、ibis で組み直した同じ中身のクエリをもう 1 回投げてた。sql_scalar を 1 回呼んだだけで、1,000 行を処理した失敗ジョブが 2 本できる。

この 2 本の 2,000 行を処理行数どおりに数えると、推定約 10.7 円。成功した C の 4,000 行が約 7.8 円やから、失敗したほうが高い。なんやそれ、と声が出た。

計測 D で sql_scalar を 1 回呼んだら、1,000 行を処理して失敗したクエリを bigframes が ibis で送り直し、失敗ジョブが 2 本になった流れ
計測 D で sql_scalar を 1 回呼んだら、1,000 行を処理して失敗したクエリを bigframes が ibis で送り直し、失敗ジョブが 2 本になった流れ

自動埋め込み生成の経路は試せていない

pandas アクセサの ai.if_ のまま最適化を効かせるなら、テーブル側の自動埋め込み生成を使うしかない。こっちは GA やけど、CLOUD_RESOURCE の接続を作って、接続のサービスアカウントに IAM を付ける必要がある。今回は接続を作らんと決めてたから、ここは手を出してへん。

費用: BigQuery AI.IF の推定コスト

先に言うとくと、ここの金額は全部推定で、請求額やない。Vertex AI の課金はジョブ統計に出えへんので、実測したトークン数に公開単価を掛けて出した。確かめられてへんことは次の 4 つ。

  • AI.IF の中で足される指示文のトークン。
  • C で LLM に回った 1,350 行のモデル。BigQuery が選ぶので分からず、A・B と同じ gemini-3.1-flash-lite の単価で計算した。
  • 蒸留モデルが処理した 2,650 行に別の課金があるか。表には入れてへん。
  • 失敗したジョブが実際に請求されるか。D と D’ は処理行数どおりに数えた。

トークン数は、同じ判定文と同じモデルで generate_bool を 50 行にかけて、usageMetadata から取った。

項目 値
入力トークン(平均) 68
出力トークン(平均) 8.82
思考トークン(平均) 0
1 行あたりの Gemini 代 約 0.0052 円

単価は 2026-11-07 に確認して、1 ドル 155 円で換算した。

  • gemini-3.1-flash-lite: 入力 $0.25 / 100 万トークン、出力 $1.50 / 100 万トークン。US マルチリージョンは 10% 高いので、$0.275 / $1.65 で計算
  • text-embedding-005: $0.000025 / 1,000 文字
  • BigQuery オンデマンド: $6.25 / TiB
パターン LLM 行 Gemini 埋め込み BigQuery 合計
A: ai.if_ 既定 4,000 20.62 円 0 円 0.01 円 約 20.6 円
B: maximize_quality 4,000 20.62 円 0 円 0.01 円 約 20.6 円
C: sql_scalar + embeddings 1,350 6.96 円 0.80 円 0.01 円 約 7.8 円
D: sql_scalar 1 回で失敗 2 本 2,000 10.31 円 0.39 円 0 円 約 10.7 円
D’: 検証用のかけ直し 1 本 1,000 5.15 円 0.20 円 0 円 約 5.4 円

埋め込み代を足しても、C は A・B の約 38% で済んだ。

D’ は検証コードが bq query 相当で直接かけ直したジョブで、普通に sql_scalar を呼ぶぶんには起きへん。

採用した実行の合計は、試運転とトークンの実測も入れて約 65 円。最初のテーブル作成で約 1.4 GiB 読んでるから、毎月 1 TiB の無料枠を使い切ってる人はプラス約 1.4 円。あと、失敗した 1 回目でも A・B の 8,000 行は LLM に回ってて、同じ計算やとこっちで 40 円ちょっと溶かしてる。

作ったデータセットは実行後に消して、残ってるリソースが無いことも確かめた。bigframes のセッション一時テーブルは session.close() で消してる。

まとめ

pandas から 1 行で LLM 判定できるのは、触ってて素直に楽しい。けど「既定で安くなる」は、bigframes 2.52.0 ではまだ看板どおりやなかった。安くしたいなら sql_scalar で埋め込みを自分で渡すしかないし、行数が足りへんとコケたうえに処理行数だけはしっかり付く。

bigframes に embeddings 引数が入ったら、真っ先に測り直すつもりや。

参考