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

この記事は、LLMアプリのログが増えたときに、PostgreSQLだけで頑張るのか、ClickHouseへ逃がすのかを考えるための検証メモです。実測値を断定する記事ではなく、1000万件の疑似LLMログを作って同じクエリを投げるためのDDLとSQLを置いています。

はじめに

LLMアプリは、ログがすぐ太ります。

普通のWebアプリなら、1リクエストに対して1行のアクセスログで足りることもあります。LLMアプリではそうはいきません。

1回のユーザー操作で、検索、rerank、model call、tool call、評価、リトライが出ます。RAGなら検索された文書、スコア、使われた文書、回答、評価も見たくなります。

最初はPostgreSQLに入れておけばいい。

自分もまずそう考えます。ユーザー、課金、権限、設定をPostgresに置いているなら、ログも同じDBに入れたくなる。小さいうちはそれで十分です。

ただ、ログが100万件、1000万件、1億件と増えると話が変わります。

「このモデルだけp95が遅い」
「プロンプトを変えてから失敗率が上がった」
「特定テナントだけトークン消費が跳ねている」
「RAGの検索件数と回答失敗率に関係がある」

こういう集計を本番DBに投げ続けるのは怖い。

そこでClickHouseです。

この記事では、LLMログ1000万件を題材に、PostgresとClickHouseの役割分担を考えます。数字を盛る記事ではなく、どこを測れば判断できるのかを整理する記事です。

この記事で見ること

  • LLMログの疑似データを1000万件作る
  • PostgresとClickHouseに同じようなテーブルを作る
  • モデル別p95、日次コスト、テナント別利用量、失敗率を集計する
  • ClickHouseでは MergeTreeORDER BY をどう決めるか考える
  • よく見る集計は materialized view に逃がす
  • Postgresを捨てるのではなく、分析だけClickHouseに逃がす線引きを考える

この記事は「Postgresは遅いから使うな」という話ではありません。Postgresはトランザクションの記録元として強いです。ここでは、増え続けるログ分析をどこまでPostgresに背負わせるかを考えます。

今回作る疑似LLMログ

まず、LLMアプリで残したくなるログを1テーブルにします。

意味
event_time リクエスト時刻
tenant_id 顧客や組織
user_id ユーザー
request_id リクエストID
model モデル名
prompt_version プロンプトのバージョン
route chat、rag、agent など
status success、error、timeout
error_type エラー種別
latency_ms レイテンシ
input_tokens 入力トークン
output_tokens 出力トークン
retrieved_docs_count RAGで取れた文書数
tool_calls tool call の回数
cost_usd 推定コスト

実アプリなら、プロンプト全文や回答全文も残したくなります。

ただ、ベンチ用の集計では入れません。個人情報や秘密情報を含みやすいし、今回見たいのは「大量の数値ログをどう集計するか」だからです。

Postgres側のテーブル

Postgresでは、まず普通にテーブルを作ります。

CREATE TABLE llm_request_logs (
    event_time timestamptz NOT NULL,
    tenant_id int NOT NULL,
    user_id int NOT NULL,
    request_id uuid NOT NULL,
    model text NOT NULL,
    prompt_version int NOT NULL,
    route text NOT NULL,
    status text NOT NULL,
    error_type text NOT NULL,
    latency_ms int NOT NULL,
    input_tokens int NOT NULL,
    output_tokens int NOT NULL,
    retrieved_docs_count int NOT NULL,
    tool_calls int NOT NULL,
    cost_usd numeric(12, 6) NOT NULL
);

1000万件を作るなら、Postgresの generate_series を使えます。1

gen_random_uuid() を使うので、必要なら先に pgcrypto を有効にします。

CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO llm_request_logs
SELECT
    now() - (g % (60 * 60 * 24 * 30)) * interval '1 second' AS event_time,
    (g % 1000)::int AS tenant_id,
    (g % 200000)::int AS user_id,
    gen_random_uuid() AS request_id,
    CASE g % 4
        WHEN 0 THEN 'gpt-4.1-mini'
        WHEN 1 THEN 'gpt-4.1'
        WHEN 2 THEN 'claude-sonnet'
        ELSE 'local-embedding-rerank'
    END AS model,
    (g % 20)::int AS prompt_version,
    CASE g % 3
        WHEN 0 THEN 'chat'
        WHEN 1 THEN 'rag'
        ELSE 'agent'
    END AS route,
    CASE
        WHEN g % 97 = 0 THEN 'timeout'
        WHEN g % 53 = 0 THEN 'error'
        ELSE 'success'
    END AS status,
    CASE
        WHEN g % 97 = 0 THEN 'upstream_timeout'
        WHEN g % 53 = 0 THEN 'tool_error'
        ELSE ''
    END AS error_type,
    (50 + (g % 3000))::int AS latency_ms,
    (100 + (g % 6000))::int AS input_tokens,
    (50 + (g % 2000))::int AS output_tokens,
    (g % 12)::int AS retrieved_docs_count,
    (g % 5)::int AS tool_calls,
    round(((100 + (g % 6000)) + (50 + (g % 2000))) * 0.0000008, 6) AS cost_usd
FROM generate_series(1, 10000000) AS g;

必要なら、よく使う条件にインデックスを貼ります。

CREATE INDEX llm_logs_time_model_idx
ON llm_request_logs (event_time, model);

CREATE INDEX llm_logs_tenant_time_idx
ON llm_request_logs (tenant_id, event_time);

CREATE INDEX llm_logs_route_status_time_idx
ON llm_request_logs (route, status, event_time);

この時点で、Postgresでも普通に集計できます。

問題は、これを本番DBで何度もやりたいかです。

ClickHouse側のテーブル

ClickHouseでは MergeTree を使います。

MergeTree 系のテーブルでは、ORDER BY が物理的な並びとスパースインデックスに効きます。公式の最適化ガイドでも、primary key、つまり多くの場合 ORDER BY の設計がクエリ性能に大きく影響すると説明されています。2

今回よく見るのは、期間、テナント、モデル、ルートです。

そこで、まずはこう置きます。

CREATE TABLE llm_request_logs
(
    event_time DateTime64(3),
    tenant_id UInt32,
    user_id UInt32,
    request_id UUID,
    model LowCardinality(String),
    prompt_version UInt16,
    route LowCardinality(String),
    status LowCardinality(String),
    error_type LowCardinality(String),
    latency_ms UInt32,
    input_tokens UInt32,
    output_tokens UInt32,
    retrieved_docs_count UInt8,
    tool_calls UInt8,
    cost_usd Decimal(12, 6)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, tenant_id, model, route);

1000万件の疑似データは、ClickHouseの numbers テーブル関数で作れます。3

INSERT INTO llm_request_logs
SELECT
    now64(3) - toIntervalSecond(number % (60 * 60 * 24 * 30)) AS event_time,
    toUInt32(number % 1000) AS tenant_id,
    toUInt32(number % 200000) AS user_id,
    generateUUIDv4() AS request_id,
    multiIf(
        number % 4 = 0, 'gpt-4.1-mini',
        number % 4 = 1, 'gpt-4.1',
        number % 4 = 2, 'claude-sonnet',
        'local-embedding-rerank'
    ) AS model,
    toUInt16(number % 20) AS prompt_version,
    multiIf(number % 3 = 0, 'chat', number % 3 = 1, 'rag', 'agent') AS route,
    multiIf(number % 97 = 0, 'timeout', number % 53 = 0, 'error', 'success') AS status,
    multiIf(number % 97 = 0, 'upstream_timeout', number % 53 = 0, 'tool_error', '') AS error_type,
    toUInt32(50 + (number % 3000)) AS latency_ms,
    toUInt32(100 + (number % 6000)) AS input_tokens,
    toUInt32(50 + (number % 2000)) AS output_tokens,
    toUInt8(number % 12) AS retrieved_docs_count,
    toUInt8(number % 5) AS tool_calls,
    toDecimal64(((100 + (number % 6000)) + (50 + (number % 2000))) * 0.0000008, 6) AS cost_usd
FROM numbers(10000000);

ORDER BY はコピペで決めないほうがいいです。期間で見るのか、テナントで見るのか、モデルで見るのかで正解が変わります。今回の並びは、期間条件をほぼ毎回入れる前提の例です。

測るクエリ1: モデル別のp95レイテンシ

LLMアプリでまず見たいのは、モデル別の遅さです。

Postgresなら、こう書けます。percentile_cont はPostgresのordered-set aggregateです。4

SELECT
    model,
    count(*) AS requests,
    percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_latency_ms,
    avg(latency_ms) AS avg_latency_ms
FROM llm_request_logs
WHERE event_time >= now() - interval '7 days'
GROUP BY model
ORDER BY requests DESC;

ClickHouseなら、quantile を使います。quantile は近似値を返す集計関数です。ダッシュボードで傾向を見るなら十分なことが多いですが、厳密な値が必要なら quantileExact なども検討します。5

SELECT
    model,
    count() AS requests,
    quantile(0.95)(latency_ms) AS p95_latency_ms,
    avg(latency_ms) AS avg_latency_ms
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 7 DAY
GROUP BY model
ORDER BY requests DESC;

ここで見たいのは、単に速い遅いではありません。

モデルごとのレイテンシを見るクエリを、ダッシュボードが何回も投げることです。1回ならPostgresでよくても、頻繁に見るなら分析DBへ逃がしたくなります。

測るクエリ2: 日次コスト

次はコストです。

ここからの集計例はClickHouse SQLを中心に書きます。Postgresで試す場合は、toDate(event_time)event_time::date に、countIf(condition)count(*) FILTER (WHERE condition) に置き換えると読み替えやすいです。

SELECT
    toDate(event_time) AS date,
    model,
    sum(input_tokens) AS input_tokens,
    sum(output_tokens) AS output_tokens,
    sum(cost_usd) AS cost_usd
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 30 DAY
GROUP BY date, model
ORDER BY date, model;

このクエリは、LLMアプリでは現実的です。

モデルを変えたあとにコストが増えたのか。特定のプロンプトだけトークンを食っていないか。顧客ごとの請求や社内のコスト配賦にも関わります。

Postgresでもできます。

でも、ログ量が増えて、同じ集計を顧客向け画面や社内ダッシュボードで何度も見るなら、ClickHouseに寄せたくなります。

測るクエリ3: プロンプト変更前後の失敗率

プロンプトを変えたあと、失敗率が上がっていないかを見ます。

SELECT
    prompt_version,
    route,
    count() AS requests,
    countIf(status != 'success') AS failures,
    round(failures / requests, 4) AS failure_rate
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 14 DAY
GROUP BY prompt_version, route
ORDER BY failure_rate DESC, requests DESC
LIMIT 20;

こういうクエリは、LLMアプリらしいです。

プロンプトはコードと違って、変更の影響が見えにくい。だからこそ、ログを残して集計したくなります。

測るクエリ4: RAG検索件数と失敗率

RAGでは、検索件数と失敗率の関係も見たいです。

SELECT
    retrieved_docs_count,
    count() AS requests,
    countIf(status != 'success') AS failures,
    round(failures / requests, 4) AS failure_rate,
    quantile(0.95)(latency_ms) AS p95_latency_ms
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 30 DAY
  AND route = 'rag'
GROUP BY retrieved_docs_count
ORDER BY retrieved_docs_count;

検索件数が多いほどよい、とは限りません。

文書を多く渡すと回答が安定することもあります。逆に、レイテンシが伸びたり、関係ない文書が混ざって回答が崩れたりすることもあります。

ここは感覚で決めるより、ログで見たいところです。

よく見る集計はmaterialized viewにする

同じ集計を何度も見るなら、毎回1000万件を読む必要はありません。

ClickHouseのmaterialized viewは、データ挿入時にSELECT結果をtarget tableへ書く仕組みとして使えます。公式ブログでも、繰り返し実行する集計を速くする用途が紹介されています。6

日次のモデル別集計を作るなら、たとえばこうです。

CREATE TABLE llm_daily_model_summary
(
    date Date,
    model LowCardinality(String),
    route LowCardinality(String),
    requests UInt64,
    failures UInt64,
    input_tokens UInt64,
    output_tokens UInt64,
    cost_usd Decimal(18, 6)
)
ENGINE = SummingMergeTree
ORDER BY (date, model, route);
CREATE MATERIALIZED VIEW llm_daily_model_summary_mv
TO llm_daily_model_summary
AS
SELECT
    toDate(event_time) AS date,
    model,
    route,
    count() AS requests,
    countIf(status != 'success') AS failures,
    sum(input_tokens) AS input_tokens,
    sum(output_tokens) AS output_tokens,
    sum(cost_usd) AS cost_usd
FROM llm_request_logs
GROUP BY date, model, route;

読む側では、SummingMergeTree の未マージ行を考えて sum() します。

SELECT
    date,
    model,
    route,
    sum(requests) AS requests,
    sum(failures) AS failures,
    round(sum(failures) / sum(requests), 4) AS failure_rate,
    sum(input_tokens) AS input_tokens,
    sum(output_tokens) AS output_tokens,
    sum(cost_usd) AS cost_usd
FROM llm_daily_model_summary
WHERE date >= today() - 30
GROUP BY date, model, route
ORDER BY date, model, route;

materialized viewは増やしすぎるとINSERT側の負荷になります。ClickHouse公式ブログでも、source tableごとのmaterialized view数は多くしすぎないほうがよいと説明されています。まずは本当に見る集計だけに絞るのが安全です。

測定結果の表はこう残す

実際に測ったら、結果はこう残すと読みやすいです。

クエリ Postgres ClickHouse メモ
モデル別p95レイテンシ 直近7日
日次コスト 直近30日
プロンプト別失敗率 直近14日
RAG検索件数別の失敗率 route = rag
日次集計テーブル materialized view使用

大事なのは、数値だけで勝ち負けを決めないことです。

見るべきなのは、次です。

  • 本番DBに重い集計を投げ続けてよいか
  • ダッシュボードの同時閲覧に耐えられるか
  • 集計のためにアプリのインデックス設計が歪んでいないか
  • ログの保存期間を伸ばせるか
  • 新しい分析軸を増やしやすいか

このあたりが苦しくなってきたら、ClickHouseを足す意味が出てきます。

Postgresを捨てる話ではない

ここは強めに書いておきたいです。

Postgresを捨てる必要はありません。

ユーザー、権限、課金、設定、注文のようなデータはPostgresに置くのが自然です。トランザクションが大事なところを、無理にClickHouseへ寄せる必要はありません。

ClickHouseに逃がしたいのは、あとから大量に読むログです。

データ 置き場所
ユーザー、権限、課金 Postgres
アプリの正規データ Postgres
LLMリクエストログ ClickHouse
RAG検索ログ ClickHouse
tool callログ ClickHouse
顧客向け集計 ClickHouse
社内ダッシュボード ClickHouse

Postgresは正規データの記録元。

ClickHouseはログと分析の逃がし先。

この分け方がいちばん現実的だと思います。

まとめ

LLMアプリは、ログが増えます。

モデル別のレイテンシ、日次コスト、プロンプト変更前後の失敗率、RAG検索件数と回答失敗率。見たいものはどんどん増えます。

Postgresでも集計はできます。

でも、ログが増え続けるなら、本番DBに分析まで背負わせるのはだんだん苦しくなります。特に、顧客向けダッシュボードや社内分析画面が何度も同じ集計を投げるようになると、役割分担を考えたくなります。

ClickHouseは、ここに置きやすいです。

MergeTree でログを置く。ORDER BY をクエリに合わせて決める。よく見る日次集計はmaterialized viewに逃がす。Postgresはトランザクションの記録元として残す。

これなら、アプリの中心を壊さずに分析だけ強くできます。

1000万件は、まだ入口です。

この規模で測っておくと、1億件になったときにどこが苦しくなるかを想像しやすくなります。LLMログをPostgresに置き続けるか、ClickHouseへ逃がすか。その判断は、雰囲気ではなくクエリで決めたいです。

参考

  1. Set Returning Functions | PostgreSQL Docs

  2. The definitive guide to ClickHouse query optimization (2026) | ClickHouse

  3. numbers table function | ClickHouse Docs

  4. Aggregate Functions | PostgreSQL Docs

  5. quantile | ClickHouse Docs

  6. Using Materialized Views in ClickHouse | ClickHouse Blog

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