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

ClickHouse Cloud で LLM を呼ぶ UDF の結果をキャッシュしてみた

1
Posted at

1. はじめに

2026 年 10 月 5 日、ClickHouse Cloud(以下、Cloud)の Executable UDF(SQL から外部のプログラムを呼び出す自作関数。以下、UDF)が一般提供になりました1。発表のブログには、UDF を呼ぶダッシュボードのクエリについて次の一文があります。

the second run of this dashboard query comes back from the cache in 0 ms with zero UDF invocations

同じクエリの 2 回目は UDF を呼ばずに、キャッシュから 0 ms で返ると書かれています。ここでいうキャッシュは ClickHouse のクエリキャッシュ(クエリの結果をサーバーに保存し、同じクエリに再利用する機能)です。UDF を含むクエリの結果をデフォルトの設定のままクエリキャッシュに保存するには、一般提供で追加された deterministic という設定を UDF に付けます。deterministic は、その UDF が同じ引数には必ず同じ結果を返すと宣言する設定です。

Docker の ClickHouse から UDF 経由で LLM の Jev を呼んだ記事2(以下、この記事を「前回の記事」と呼びます)の考察で、「DB から LLM を呼ぶと、同じクエリを実行し直すたびに呼び直しになり、そのたびに課金される」と書きました。UDF を含むクエリの結果がクエリキャッシュから返るなら、この呼び直しはどこまで防げるのか。レプリカ 2 台の Cloud のサービスで試してみました。

1.1. 結論(先出し)

  • deterministic を付けた UDF を含むクエリは、クエリキャッシュが作られてから 60 秒(デフォルト)以内に同じレプリカで実行し直すと、UDF を呼ばずに約 1 ms で返った
  • クエリキャッシュはレプリカごとにある。2 台のサービスで同じクエリを 10 回実行すると、各レプリカの最初の 1 回だけ UDF が呼ばれた(計 2 回)。期限を過ぎたクエリと、条件を置き換えたクエリでも UDF が呼ばれ、空白と改行だけ変えたクエリでは呼ばれなかった
  • クエリキャッシュは、そのレプリカで最初に返った値を期限まで返す。2 台が最初に保存した値は 100 行中 19 行で違い、確率が 0.5 付近だった 1 行はレプリカによって判定が一致しなかった
  • 結果をテーブルに保存すると、読み出しでは UDF が呼ばれず、どちらのレプリカからも同じ値が返った

1.2. 検証ゴール

# 確かめること 確認できれば OK の条件
1 Cloud の UDF から Jev を呼べるか API キーを SQL に書かずに、レビュー 100 行の判定結果(確率)が返る
2 deterministic を付けると、同じクエリの再実行で Jev を呼ばずに済むか 付けない UDF ではデフォルトの設定でクエリキャッシュを指定できず、付けた UDF では同じレプリカでの 2 回目以降に UDF が呼ばれない
3 クエリキャッシュが使われないのはどんなときか 別のレプリカで実行されたとき、期限を過ぎたとき、クエリの書き方を変えたときのそれぞれで、クエリキャッシュが使われたかどうかが実測で分かっている

2. 検証環境

項目 内容
サービス ClickHouse Cloud(AWS 東京リージョン、Scale プラン)
バージョン ClickHouse 26.6.1.2326
レプリカ 2 台(各 12 GiB・3 vCPU)
UDF Python 3.11、Executable Pool、TabSeparated(TSV)
LLM Jev(TypeSafe AI、jev-latest)3
データ Amazon のレビュー 100 行(星 1〜2 と 4〜5 を 50 行ずつ。本文は先頭 300 文字)
クエリの送信 HTTPS インターフェース(8443)

構成を図 1 に示します。

図 1: 検証の構成

図 1: 検証の構成

今回は、クエリ(HTTPS のリクエスト)ごとに 2 台のどちらかで実行されました。どちらで実行するかを指定する機能は Enterprise プランのもので(6.1 章)、今回は使えないため、各クエリが実行されたレプリカを hostName() で記録しました。

データは前回の記事と同じ Amazon のレビューから取り、星 4〜5 を肯定、星 1〜2 を否定の正解として使います。

3. ClickHouse Cloud で UDF を作る

3.1. UDF の本体

UDF は前回の記事のスクリプトを Cloud 向けに直したものです。Jev の API には、判定の材料になる文章(state)と、型付きの質問をまとめて送ります。ここでは「レビュー [i] は商品に肯定的か」という真偽の質問(Noul。0〜1 の確率で答えが返る)を使います。UDF は受け取った行を 50 行ずつまとめ、50 問を 1 回のリクエストで送ります。100 行なら Jev へのリクエストは 2 回です。

API キーの置き場所は前回の記事から変わります。前回の記事では docker compose の環境変数で UDF に渡していましたが、2026 年 10 月時点の Cloud の UDF にはシークレットストアが無く、公式 Docs には設定ファイルを zip に同梱する方法が書かれています4。

There is no secrets store for UDFs yet. Bundle a config file in the zip and read it relative to __file__; never put keys in SQL.

そこで main.py と、キーを書いた config.json を 1 つの zip にして、コンソールからアップロードしました。キーはこの検証のために発行したもので、検証が終わったら失効させます。

main.py(抜粋)
import sys, os, json
from concurrent.futures import ThreadPoolExecutor

# キーは zip に同梱した config.json から読む(SQL には書かない)
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json"), encoding="utf-8") as f:
    KEY = json.load(f)["jev_api_key"]
N = 50
EXECUTOR = ThreadPoolExecutor(max_workers=8)

# call_jev(rows): rows を state に番号付きで並べ、Noul の質問を len(rows) 問付けて 1 回 POST する(前回の記事と同じ)

def process_chunk(rows):
    groups = [(s, rows[s:s + N]) for s in range(0, len(rows), N)]
    results = [None] * len(rows)
    for start, probs in EXECUTOR.map(lambda g: (g[0], call_jev(g[1])), groups):
        results[start:start + len(probs)] = probs
    return results

# send_chunk_header を有効にしているので、各ブロックの先頭に行数が 1 行で届く
while True:
    header = sys.stdin.readline()
    if header == "":
        break
    rows = [sys.stdin.readline().rstrip("\n") for _ in range(int(header))]
    for p in process_chunk(rows):
        sys.stdout.write(("nan" if p is None else repr(p)) + "\n")
    sys.stdout.flush()

Jev の呼び出しに失敗した行は nan を返します。2026 年 10 月時点のコンソールでは戻り値の型に Nullable を選べないため、前回の記事では NULL を返していた処理を、nan を返すように変えました。

3.2. コンソールでの設定

UDF は組織の設定の「User-defined functions」で作ります。図 2 は deterministic を付けた UDF の入力内容で、コンソールでは Deterministic の項目を True にします。

図 2: Deterministic を True にした UDF の設定

図 2: Deterministic を True にした UDF の設定

比べるために、deterministic を付けるかどうかだけが違う UDF を 2 つ作りました。中身の zip は同じです。

UDF Deterministic Type Pool size Send chunk header Max command exec time
jev_noul_nd False Executable Pool 1 True 300 秒
jev_noul_det True Executable Pool 1 True 300 秒

コンソールで作る Python の UDF は、外部への通信が有効な状態で作られます4。前回の記事で Docker の ClickHouse を使ったのは、当時の Cloud では外部と通信する UDF が限定公開のベータ(private beta)で、使うにはサポートへの問い合わせが必要だったためです。一般提供で、すべての組織で使えるようになりました1。

4. 計測の方法

判定のクエリは次の 1 本です。100 行それぞれの確率を返します。5.3 章で条件式だけを置き換えたクエリと比べるため、WHERE 1 = 1 を置いています。

SELECT review_id, star_rating, jev_noul_det(review_body) AS p
FROM default.reviews_eval100
WHERE 1 = 1
ORDER BY review_id
FORMAT TSV

これに use_query_cache = 1 を付けて実行し、各回について system.query_log から次の値を取りました。clusterAllReplicas を使い、2 台分の記録を取得しています。

値 取り出し方
UDF の呼び出し回数 ProfileEvents['ExecutableUserDefinedFunctionInvocations']
クエリキャッシュに当たったか ProfileEvents['QueryCacheHits'] / ['QueryCacheMisses']
実行されたレプリカ hostName()
所要時間 query_duration_ms

UDF の呼び出し回数は、UDF にデータのブロックを渡した回数です。今回の 100 行は 1 ブロックで渡るので、Jev を呼んだクエリでは 1、クエリキャッシュから返ったクエリでは 0 になります。このカウンターは 26.6 以降で記録されます4。クエリキャッシュに保存された 1 件の結果(system.query_cache の 1 行)を、以下ではエントリと呼びます。

クエリを見分けるラベル(log_comment)とクエリキャッシュの指定は、クエリ文の SETTINGS ではなく HTTP の URL パラメータで渡しました。URL パラメータで渡した設定はクエリ文に入らないので、回ごとにラベルを変えてもクエリ文は同じままです。クエリキャッシュは、クエリ文を構文木(AST)に変換してから、同じクエリかどうかを見分けます。公式 Docs には、構文木から除かれるのはクエリキャッシュと出力形式に関する設定だと書かれています5。log_comment はどちらにも該当しないので、ラベルをクエリ文の SETTINGS に書くと、回ごとに別のクエリとして扱われます。

5. 結果

時刻はすべて日本時間です。2 台のレプリカを、ここでは A と B と呼びます。判定は確率 0.5 以上を肯定、0.5 未満を否定とします。

5.1. deterministic を付けない UDF はクエリキャッシュを指定できない

jev_noul_nd(deterministic なし)を含むクエリに use_query_cache = 1 を付けると、デフォルトの設定ではクエリが始まる前にエラーになりました。UDF の呼び出しは 0 回で、Jev は呼ばれていません(ゴール 2)。

Code: 704. DB::Exception: The query result was not cached because the query contains a non-deterministic function. Use setting `query_cache_nondeterministic_function_handling = 'save'` or `= 'ignore'` to cache the query result regardless, or omit caching. (QUERY_CACHE_USED_WITH_NONDETERMINISTIC_FUNCTIONS)

公式 Docs のとおり、UDF は何も指定しなければ非決定的(同じ入力でも結果が変わりうる)として扱われます4。

5.2. 同じクエリを 10 回実行した結果

jev_noul_det(deterministic あり)で、同じクエリを約 1 秒おきに 10 回実行しました。

回 時刻 実行されたレプリカ クエリキャッシュ UDF の呼び出し 所要時間
1 23:04:15 A 外れ 1 回 217 ms
2 23:04:17 B 外れ 1 回 210 ms
3 23:04:18 B 当たり 0 回 1 ms
4 23:04:19 B 当たり 0 回 1 ms
5 23:04:20 A 当たり 0 回 1 ms
6 23:04:21 B 当たり 0 回 1 ms
7 23:04:22 A 当たり 0 回 1 ms
8 23:04:23 B 当たり 0 回 1 ms
9 23:04:24 A 当たり 0 回 1 ms
10 23:04:25 B 当たり 0 回 1 ms

1 回目は 100 行すべてに確率が返り(nan は 0 行)、API キーを SQL に書かずに Cloud の UDF から Jev を呼べました(ゴール 1)。

1 回目で A にエントリができた後も、2 回目に B で実行されたクエリは外れ、B でも UDF が呼ばれました(ゴール 3)。3 回目以降は A・B のどちらで実行されても当たり、UDF は呼ばれていません(ゴール 2)。UDF が呼ばれたのは 2 回で、3.1 章の UDF は 1 回の呼び出しで 2 リクエスト送るので、Jev へのリクエストは計 4 回になります。

10 回目の直後に system.query_cache を 2 台分見た結果が次です(時刻は UTC で、日本時間では 9 時間後)。

host  stale  expires_at
A         0  2026-10-09 14:05:15
A         1  2026-10-09 13:33:01
B         0  2026-10-09 14:05:16

stale が 0(期限内)のエントリは、A と B に 1 件ずつありました。期限は A が 23:05:15、B が 23:05:16 で、それぞれ 1 回目・2 回目のクエリが始まってから 60 秒後です(秒未満は切り捨てて表示)。3〜10 回目で当たった後も、期限は延びていません。A の stale が 1 の行は、約 30 分前に別の計測で作られた期限切れのエントリです。

5.3. クエリキャッシュが使われる条件と使われない条件

試したこと 時刻 回数 外れた回 当たった回
期限を過ぎてから同じクエリ 23:05:42〜23:05:47 6 回(A 2 回・B 4 回) 2 回(B・A の 1 回目) 4 回
空白と改行だけ変えたクエリ 23:05:58〜23:06:04 6 回(A 5 回・B 1 回) 0 回 6 回
条件式を WHERE star_rating >= 1 に置き換えたクエリ(結果は同じ 100 行) 23:06:15〜23:06:18 4 回(B 4 回) 1 回(B の 1 回目) 3 回

期限を過ぎた後は、2 台ともそれぞれの 1 回目で UDF が呼ばれました(ゴール 3)。直前の 23:05:41 に system.query_cache を見ると、2 台ともエントリの stale が 1 になっており、期限切れのエントリは使われていません。

空白と改行だけを変えたクエリは、元の書き方のクエリが作ったエントリ(期限切れの後に作り直されたもの)に 6 回とも当たりました。4 章で書いたとおり、クエリキャッシュは文字列ではなく構文木で同じクエリかどうかを見分けています。

条件式を置き換えたクエリは、結果が同じ 100 行でも別のクエリとして扱われました(ゴール 3)。この時刻、B には元のクエリの期限内のエントリ(23:05:42 作成)がありましたが、1 回目で UDF が呼ばれています。4 回とも B で実行されたため、A では試せていません。

5.4. レプリカごとに保存された値

5.2 章・5.3 章で返った 100 行の値を、組にして比べました。

比べた組 値が違った行 差の最大 判定が逆になった行
5.2 章の 1 回目(A、外れ)と 5 回目(A、当たり) 0 行 0 0 行
5.2 章の 2 回目(B、外れ)と 3 回目(B、当たり) 0 行 0 0 行
5.2 章の 1 回目(A)と 2 回目(B) 19 行 0.03 1 行
5.2 章の 1 回目(A)と期限切れ後の A 18 行 0.06 1 行
5.2 章の 2 回目(B)と期限切れ後の B 20 行 0.03 1 行

クエリキャッシュから返った値は、A・B とも、そのレプリカで最初に Jev を呼んだときの値と一致しました。一方、A と B がそれぞれ最初に呼んだときの値は 19 行で違い、期限切れの後に呼び直した値も、期限前の値と 18〜20 行で違いました。Jev は同じ入力にも毎回同じ値を返すわけではなく、各レプリカのエントリには、そのレプリカで最初に返った値が入ります。

判定が逆になった 1 行は、星 4 のレビューでした。「良いが、主力よりは予備向き」という趣旨の文で、5.2 章では A が 0.49(否定)、B が 0.50(肯定)でした。Jev が返す確率は今回すべて小数 2 桁で、0.50 は丸めた値ではありません。期限切れの後に呼び直すと、A が 0.50、B が 0.49 と、A と B の値が逆になりました。

5.5. 結果をテーブルに保存した場合

同じ判定の結果を CREATE TABLE ... AS SELECT でテーブルに保存しました。UDF が呼ばれるのは保存のときだけで、その後の読み出しは 3 回とも UDF の呼び出し 0 回、3〜4 ms でした。3 回のうち 2 回は B、1 回は A で実行され、どちらからも同じ値が返りました。

5.6. now() を含むクエリ

保存したテーブルに WHERE now() > toDateTime('2026-01-01 00:00:00') を足したクエリに use_query_cache = 1 を付けると、5.1 章と同じエラー 704 になりました。UDF を含まないクエリでも、now() があるとデフォルトの設定ではクエリキャッシュを指定できません。

6. 考察

6.1. クエリキャッシュで防げる呼び直しと、防げない呼び直し

クエリキャッシュで呼び直しを減らせたのは、同じレプリカで実行され、エントリが作られてから 60 秒以内で、構文木が同じクエリだけでした(5.2 章・5.3 章)。レプリカが N 台なら、期限内でも同じクエリで UDF が最大 N 回(各レプリカで 1 回ずつ)呼ばれると考えられます。

60 秒はデフォルト値で、query_cache_ttl で変えられます。ただし期限を延ばしても、クエリキャッシュはサーバープロセスごとに持つ仕組みのままです5。今回も、エントリはレプリカごとに別々にありました(5.2 章)。

クエリキャッシュは ClickHouseサーバープロセスごとに 1 つ存在します。

同じレプリカに送る方法としては、Replica-aware routing があります6。HTTPS のリクエストに X-ClickHouse-Replica-Tag ヘッダーを付けると、同じ値のリクエストは同じレプリカに送られます。公式 Docs には、用途の 1 つとしてレプリカごとのキャッシュの再利用が書かれています。2026 年 10 月時点では Enterprise プランのみのベータ機能で、レプリカ数を変えると送り先が変わる場合がある、とも書かれています。今回のサービスは Scale プランのため試していません。

ダッシュボードで使うときは、5.6 章のとおり、now() を含むクエリにはデフォルトの設定ではクエリキャッシュを指定できない点も制約になります。公式 Docs にも、now()・today()・rand() のような関数が同じクエリにあると、デフォルトではキャッシュされないと書かれています4。「直近 1 時間」のように now() で期間を区切るダッシュボードのグラフは、デフォルトの設定のままなら、期間を固定の値で渡すクエリにしないとクエリキャッシュを使えないと考えられます。その場合も、渡す値が変われば別のクエリとして扱われます。

前回の記事で書いた呼び直しの課金は、同じクエリを短い間隔で何度も実行する場面ならクエリキャッシュで減らせます。一方、テーブルに保存した結果は、どちらのレプリカから読んでも同じ値が返り、UDF も呼ばれませんでした(5.5 章)。翌日にバッチを実行し直す場合や、レプリカをまたいで同じ値が必要な場合は、テーブルへの保存が合うと考えられます。

6.2. LLM を呼ぶ UDF で値を固定しているのはクエリキャッシュ

公式 Docs には、deterministic は同じ引数に必ず同じ結果を返す関数に限って付けるよう書かれています4。

Enable Deterministic only when the function always returns the same result for the same arguments.

5.4 章のとおり、A と B がそれぞれ最初に呼んだ Jev の値は 100 行中 19 行で違いました。Jev はこの条件を満たしません。それでも deterministic を付けた UDF を use_query_cache = 1 で実行すると、最初に返った値がそのレプリカのクエリキャッシュに保存され、期限まではその値が返ります。値を固定しているのはクエリキャッシュで、LLM 自体の答えが固定されるわけではありません。

その結果、確率が 0.5 付近だった 1 行は、どちらのレプリカで実行されたかで判定が一致しませんでした。0.5 で区切って件数を数える集計では、今回のデータでもレプリカによって肯定の件数が 1 件違いました。

deterministic を付けずにクエリキャッシュを使う方法もあります。公式 Docs には、query_cache_nondeterministic_function_handling を 'save' にすると、非決定的な関数を含むクエリもキャッシュされると書かれています4。今回は試していません。

7. まとめ

Cloud の一般提供で追加された deterministic を UDF に付け、LLM(Jev)を呼ぶクエリでクエリキャッシュを試しました。同じレプリカで期限内に実行し直した同じクエリは、UDF を呼ばずに約 1 ms で返りました。ただし、クエリキャッシュはレプリカごとにあり、各レプリカには最初に返った値が保存されるので、レプリカによって値が違うことがあります。そのため、now() を含まない同じクエリを何度も実行するダッシュボードではクエリキャッシュ、レプリカをまたいで同じ値が必要な集計ではテーブルへの保存、と使い分けられます。

参考


ClickHouse、ClickHouse のロゴ、および関連するマークは、ClickHouse, Inc. またはその関連会社の商標または登録商標です(https://clickhouse.com)。

  1. Executable UDFs are generally available on ClickHouse Cloud(ClickHouse 公式ブログ、2026-10-05。一般提供で追加された機能の一覧) ↩ ↩2

  2. ClickHouse の SQL から Jev を呼んで、レビュー 1,000 行をまとめて判定してみた(前回の記事) ↩

  3. TypeSafe AI Docs: Quickstart(Jev の API の呼び方) ↩

  4. User-defined functions(ClickHouse Cloud の公式 Docs。Deterministic functions and the query cache・Network access・Monitoring の節。日本語版には 2026 年 10 月時点でこれらの節が無いため英語版を参照) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  5. クエリキャッシュ(ClickHouse の公式 Docs。設定と使用方法の節) ↩ ↩2

  6. Replica-aware routing(ClickHouse Cloud の公式 Docs。Enterprise プランのベータ機能。日本語版で該当の記述を確かめられなかったため英語版を参照) ↩

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