從 .NET 傳統架構到 Python / Cloud-Native AI 平台,我們在 Navi 2.0 用 14 個月時間,讓「自然語言查資料庫」這件事真的能跑在 production。這篇分享從架構設計、Prompt 工程、多雲切換到評估方法的完整實戰心得。
在企業內部,資料分析的最大痛點不是「資料不夠」,而是「會 SQL 的人太少」。產品端、業務端、客服端每天有大量的「我想知道某 X 的資料」需求,最後都變成工程師排隊改 ad-hoc query。
我們在蓋亞做 Navi 2.0 時,核心目標就是讓任何一個業務同事都能用中文問問題,系統自己生 SQL、查資料庫、回應結果。
市面上有很多 Text2SQL 解決方案(LangChain SQLDatabaseChain、Vanna、DataHerald 等),但跑下來都遇到同樣的問題:
- 跨資料庫支援不一致——MySQL 跟 MSSQL 的方言、保留字、JOIN 語法不同
- Schema 太大會 token 爆炸——直接把 schema 全塞給 LLM,動輒 50K+ token
- 準確率不穩——同一個問題第一次答對,第二次答錯
- 單一 LLM 風險——綁死 OpenAI 等於把成本與可用性都交給單一供應商
於是我們決定自己做。
┌─────────────────────────────────────┐
│ User Natural Language │
│ "上個月銷售前三名的商品是什麼?" │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Question Classifier │
│ (查詢類型 / 複雜度 / 表格範圍判斷) │
└─────────────────┬───────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Schema │ │ Few-Shot │ │ Business │
│ Retriever │ │ Selector │ │ Rules │
│ (vector DB) │ │ (RAG) │ │ Injector │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
└─────────────────────┼─────────────────────┘
▼
┌─────────────────────────────────────┐
│ Prompt Composer │
│ (system + schema + examples + Q) │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ LLM Router │
│ ┌──────────┬──────────┬──────────┐ │
│ │ Azure │ AWS │ GCP │ │
│ │ OpenAI │ Bedrock │ Vertex │ │
│ │ (primary)│ (POC) │ (POC) │ │
│ └──────────┴──────────┴──────────┘ │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ SQL Validator & Sanitizer │
│ (read-only, schema check, sandbox)│
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Database Executor │
│ (MySQL / PostgreSQL / MSSQL) │
└─────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Result Formatter & Cache │
└─────────────────────────────────────┘
直接把 schema 全塞給 LLM 是新手最常犯的錯誤。我們的資料庫有 200+ 表,full schema 大約 60K token,每次查詢都送一份成本完全不可控。
我們的做法:Schema-as-Vector
-
建索引時:把每個表結構轉成自然語言描述,用 embedding model 編碼到 vector DB
表名:order_items 描述:訂單明細表,記錄每筆訂單包含的商品、數量、單價 主要欄位:order_id (FK), product_id (FK), quantity, unit_price 關聯:JOIN orders ON order_items.order_id = orders.id JOIN products ON order_items.product_id = products.id -
查詢時:把使用者問題編碼,從 vector DB 取最相關的 5-10 個表
-
動態組裝 prompt:只把這些表的 schema 放進 prompt,token 從 60K 降到 3-5K
效果:平均 token 用量降低 92%,且 LLM 不會被無關 schema 干擾。
Text2SQL 不是「丟個問題給 LLM」就有答案,prompt 設計細節直接決定準確率。
我們維護一個範例庫(約 200 個 question-SQL pair),每次查詢時:
- 計算使用者問題與每個範例的 embedding 相似度
- 取 top-5 最相似的範例放進 prompt
- 確保 5 個範例涵蓋不同的 SQL 模式(JOIN、聚合、子查詢、視窗函數)
你是一位資料庫專家。請根據以下 schema 與範例,將使用者問題轉換為 SQL。
[Schema 區]
{retrieved_schemas}
[範例區]
{few_shot_examples}
[業務規則區]
- 訂單狀態 status=1 表示已付款
- 商品表 deleted_at 不為 NULL 表示已下架,查詢時請排除
- 日期欄位皆為 UTC,需轉換為 +08:00
[使用者問題]
{user_question}
請依以下步驟思考:
1. 識別問題涉及哪些表
2. 規劃 JOIN 順序與條件
3. 套用必要的 WHERE 過濾(包含業務規則)
4. 決定 GROUP BY、ORDER BY、LIMIT
5. 輸出最終 SQL(只回 SQL,不要解釋)
CoT 讓準確率提升約 15%,代價是 token 略增。
只用單一 LLM 有三大風險:
- 單點故障(2024 年 GPT-4 曾連續 4 小時 outage)
- 成本綁死(供應商漲價只能吞)
- 能力差異(某些 query 用 Claude 表現更好)
class LLMRouter:
def route(self, question: str, complexity: str) -> LLMProvider:
# 簡單查詢用便宜模型
if complexity == "simple":
return AzureOpenAI("gpt-4o-mini") # 主力,成本低
# 複雜查詢用強模型
if complexity == "complex":
return AzureOpenAI("gpt-4o") # 主力
# A/B test 流量切到 POC 平台
if self.in_ab_test_group(question):
return random.choice([
AwsBedrock("claude-sonnet-4-5"),
GcpVertex("gemini-2.0-pro")
])
return AzureOpenAI("gpt-4o") # 預設主 LLM 失敗(timeout / rate limit / API 錯誤)時,自動切到備援:
Azure OpenAI → AWS Bedrock → GCP Vertex AI → 降級到 cached response
每次切換都記錄到 OpenTelemetry,方便 SRE 監控。
注意:Bedrock 與 Vertex AI 在我們系統中還在 POC 階段,production 主力仍是 Azure OpenAI。多雲是「備援與成本優化」的策略,不是「同時三家全跑」。
LLM 生出的 SQL 絕對不能直接執行,有三大風險:
- SQL injection(LLM 可能被使用者問題注入)
- 不可逆操作(LLM 寫出 DELETE / UPDATE / DROP)
- 效能殺手(LLM 寫出 cross join 把 DB 拖垮)
LLM SQL Output
│
▼
┌─────────────────────────────┐
│ 1. AST 解析 (sqlparse) │
│ - 是否為 SELECT? │
│ - 是否含 DDL/DML? │
└─────────┬───────────────────┘
│ pass
▼
┌─────────────────────────────┐
│ 2. Schema 驗證 │
│ - 表名/欄位是否存在? │
│ - JOIN 條件是否合法? │
└─────────┬───────────────────┘
│ pass
▼
┌─────────────────────────────┐
│ 3. EXPLAIN PLAN 分析 │
│ - 估計 row count │
│ - 是否走 full scan? │
└─────────┬───────────────────┘
│ pass
▼
┌─────────────────────────────┐
│ 4. Read-only 帳號執行 │
│ - 設定 query timeout │
│ - 限制 result row limit │
└─────────────────────────────┘
任一步失敗就 reject 並重新生成 SQL。
很多人做完 Text2SQL 就說「我覺得蠻準的」,但「覺得」不是工程。
我們花 2 週建立 150 題 Golden Set:
- 涵蓋 simple / medium / complex 三種難度
- 每題由業務同事提供問題,DBA 寫標準 SQL
- 涵蓋 JOIN、聚合、子查詢、視窗函數、日期函數六大類
| 指標 | 定義 |
|---|---|
| Execution Accuracy | LLM 生成的 SQL 跑出來的結果是否與 Golden SQL 結果一致 |
| Exact Match | LLM 生成的 SQL 是否與 Golden SQL 字串完全相同(嚴格) |
| Semantic Match | LLM 生成的 SQL 結構是否與 Golden SQL 等價(允許 alias 不同) |
每次改 prompt 或換模型,自動跑 Golden Set 並比對:
pytest tests/eval/test_text2sql.py --eval-set golden_v3
# Output:
# Simple: 98.5% (148/150)
# Medium: 91.2%
# Complex: 76.8%
# Total: 88.9% Execution Accuracy低於 baseline 就 block PR,確保改動不會 regression。
症狀:LLM 「腦補」了一個 customer_email 欄位,但實際表只有 email
對策:Schema 驗證階段直接 reject,並把錯誤欄位 + 正確欄位列表餵回去重生
症狀:200+ 表的 schema 全塞,每次查詢 60K token,成本一個月燒掉幾萬美元 對策:Schema-as-Vector(上面講過)
症狀:三個工程師同時改 prompt,沒人知道哪版的準確率最高 對策:把 prompt 當成 code 管理,寫入 Git,每版跑 Golden Set,結果存到 metric DB
症狀:使用者問「2024 年第一季的 revenue」,LLM 把「revenue」當英文不翻譯,但 SQL 欄位是 總營收
對策:Schema 描述同時用中英雙語標記,並在 system prompt 提醒「術語對照表」
這個專案的另一層收穫,是全程使用 Cursor 與 Claude Code 開發的體驗。
過去寫一個複雜的 retriever 模組,可能要花 2-3 天看 LangChain 文件、寫測試、debug。現在用 Cursor 的 multi-file edit + Claude 的 agentic mode,從設計到測試大概 4-6 小時就能完成一個可用的版本。
但這不代表 AI 取代工程師。我發現的關鍵是:
AI 工具放大的是工程師的「品味」——你越知道好程式長什麼樣,AI 給你的價值越高;越不會 review 程式碼,AI 給你的就只是一坨 OK-ish 的 spaghetti。
如果有什麼一句話心得帶走,我會說:好的工程師不只用 AI,還要 review AI。
- 加入 Schema 自動更新機制(資料庫變更時自動重建 vector index)
- 探索 Multi-Agent 模式(規劃 Agent + 生成 Agent + 驗證 Agent 分工)
- 整合 MCP Protocol 讓 Text2SQL 變成可插拔的 LLM tool
- 建立 Prompt A/B test 平台,系統化迭代
- 在你的 GitHub 建一個 repo,例如
text2sql-engineering-notes - 把這篇 .md 命名為
README.md或text2sql-from-zero.md - 加上架構圖(可以用 mermaid 重畫,或上傳 PNG)
- Push 上去
- 重點:在你的 GitHub Profile README 裡 pin 這個 repo
或者更進階的做法:用 GitHub Pages 做 blog
- Repo 建
your-username.github.io - 用 Jekyll、Hugo、Astro 任一框架
- 把這篇放進
_posts/或content/posts/ - 自動生成 blog 網站
- Email:ko1314520ya@gmail.com
- GitHub:D11225687
- 地點:台北