審 AI 產生的 SQL,不能只看語法成功與結果像不像。至少要核對資料 grain、JOIN cardinality、filter、NULL、時間邊界、聚合分母,以及 LIMIT/抽樣;每一項都應搭配小型驗證查詢。SQL 能跑只證明引擎接受它,不證明它回答了業務問題。
這個落差在企業情境尤其明顯。2026 年 EntSQL benchmark 收錄五個領域、1,066 組中英對照題目,多數需要問題與 schema 之外的內部指標、報表慣例或組織規則;在該研究提供長篇企業文件的英文設定中,最佳受測系統只有 15.9%。這是特定 benchmark、版本與評估方式的結果,不能當成所有 Text-to-SQL 的通用準確率,但它說明「有 schema」不等於「懂口徑」。
以下用「計算 2026 年 6 月完成訂單的客戶平均消費」為例。假設 orders 一列一張訂單,order_items 一列一個品項。先別急著看平均數,依序審七點。
1. Grain:結果的一列代表什麼?
先用一句話寫出每張來源表與結果的 grain:
orders:一列一張訂單
order_items:一列一個訂單品項
結果:一列一位客戶
再檢查 key 是否符合假設:
SELECT
count(*) AS rows,
count(DISTINCT order_id) AS distinct_orders
FROM orders;
若兩者不同,order_id 不是唯一,或資料已有重複。沒有 grain,後面的平均、distinct 與 JOIN 都只能靠運氣。
2. JOIN cardinality:有沒有 fan-out?
一張訂單 JOIN 多個品項後,orders.total_amount 會被複製。以下 SQL 語法正確,總額卻可能被放大:
SELECT sum(o.total_amount)
FROM orders o
JOIN order_items i USING (order_id);
在 JOIN 前後比較 row count 與 distinct key:
SELECT
count(*) AS joined_rows,
count(DISTINCT o.order_id) AS distinct_orders
FROM orders o
JOIN order_items i USING (order_id);
如果只需要確認存在某種品項,可用 EXISTS;如果要算品項金額,先按 order_id 聚合 items,再 JOIN。不要用 SELECT DISTINCT 隱藏尚未理解的 fan-out。
3. Filter:包含與排除條件完整嗎?
「完成訂單」可能是 paid、fulfilled,是否排除退款、測試帳號與內部訂單?逐項把自然語言對到 WHERE:
WHERE status IN ('paid', 'fulfilled')
AND is_test = false
AND refunded_at IS NULL
接著看被排除的分布,而不是只看保留資料:
SELECT status, count(*) AS n
FROM orders
GROUP BY status
ORDER BY n DESC;
狀態定義來自業務文件或 owner,不應由模型根據欄名自行推斷。
4. NULL:三值邏輯會排掉誰?
WHERE country <> 'TW' 不會保留 country IS NULL 的列;sum(amount) 會忽略 NULL;count(column) 與 count(*) 也不同。先量化:
SELECT
count(*) AS rows,
count(customer_id) AS rows_with_customer,
count(*) FILTER (WHERE customer_id IS NULL) AS missing_customer
FROM orders;
然後明確決定 NULL 是未知、無此值,還是資料錯誤。COALESCE 是業務處理,不是清理魔法。
5. 時間邊界:用半開區間,先說清時區
月份篩選建議使用 [start, end),避免漏掉有時間的小數秒:
WHERE created_at >= TIMESTAMPTZ '2026-06-01 00:00:00+08:00'
AND created_at < TIMESTAMPTZ '2026-07-01 00:00:00+08:00'
但這只在 created_at 的型別與來源時區已確認時才成立。還要問:以建單、付款還是完成時間歸月?跨日完成算哪一個 cohort?日期寫對了,事件選錯仍會答非所問。
6. Aggregation 與 denominator:平均的是訂單還是客戶?
AVG(total_amount) 是平均訂單金額,不是「每位客戶平均消費」。後者應先按客戶聚合,再平均:
WITH customer_spend AS (
SELECT customer_id, sum(total_amount) AS spend
FROM orders
WHERE status IN ('paid', 'fulfilled')
AND created_at >= TIMESTAMPTZ '2026-06-01 00:00:00+08:00'
AND created_at < TIMESTAMPTZ '2026-07-01 00:00:00+08:00'
GROUP BY customer_id
)
SELECT
count(*) AS customers,
avg(spend) AS avg_spend_per_customer
FROM customer_spend;
同時輸出 denominator count,Report 不要只留 rate 或 average。COUNT(DISTINCT customer_id) 也不能修復上游定義不清。
7. LIMIT、抽樣與排序:結果是全量還是預覽?
AI 在探索時常用 LIMIT 100。如果 LIMIT 被留在 CTE 或聚合前,最終結果可能只代表一小部分資料。沒有 ORDER BY 的 LIMIT 也不保證是「最新」或「隨機」樣本。
審查時搜尋 LIMIT、TABLESAMPLE 與取樣條件,確認它們只服務 preview,或在 Report 明確揭露抽樣設計。若要 top 10,必須確認排序指標與 tie 處理。
一組最小驗證查詢,不要只跑主查詢
正式 SQL 至少配三種 checkpoint:
- 體量:來源與每個主要 filter 前後的 row/distinct key。
- 分布:狀態、日期、NULL 與極端值分布。
- 對帳:選一小段已知資料,手算或與可信報表比較。
例如:
-- Checkpoint:每個 filter 後剩多少訂單與客戶
SELECT
count(*) AS orders,
count(DISTINCT customer_id) AS customers,
min(created_at) AS min_ts,
max(created_at) AS max_ts,
sum(total_amount) AS total_amount
FROM orders
WHERE status IN ('paid', 'fulfilled')
AND created_at >= TIMESTAMPTZ '2026-06-01 00:00:00+08:00'
AND created_at < TIMESTAMPTZ '2026-07-01 00:00:00+08:00';
Checkpoint 的期望不一定是固定數字,也可以是「orders 不少於 customers」「時間不超出 6 月」「NULL customer 為零」。把 expected condition 寫出來,才能在資料更新後重跑驗證。
Lantide Data 如何讓 SQL Review 不脫離分析契約
Lantide Data 採 SQL-first:Plan 說明業務問題、grain、分母與 checkpoints;持久 SQL 分頁保存取數邏輯;Report 保存結論與 limitations。Agent 可草擬 SQL,但正式 Project Analysis 由使用者審閱 Plan 後按 Approve & Execute,SQL 與 execution evidence 留在同一分析脈絡。流程見 SQL-first 工作流 與 Plan → Execute → Report。
持久分頁執行後的快取可右鍵 查看 SQL;上游分頁可用 Source Run 依賴重跑與查看 lineage。這讓 reviewer 能從 Report 數字回到查詢,而不是翻聊天。但 Lantide 不保證 AI SQL 正確:grain、業務狀態、時間與排除條件仍需資料 owner/分析師判斷;快取也是暫存中間結果,不是永久數倉。
結語:把「能跑」改成「能解釋、能驗證」
下次審 AI SQL,先不要看最後一格數字。依序寫出 grain,檢查 JOIN、filter、NULL、時間、denominator 與 LIMIT,再加三類 checkpoint。只有當主查詢與驗證查詢能共同支持同一個業務問題,這段 SQL 才值得進 Report。