Skip to content

Latest commit

 

History

History
337 lines (250 loc) · 14.5 KB

File metadata and controls

337 lines (250 loc) · 14.5 KB

從零打造 Text2SQL 引擎:多雲 LLM 切換策略與工程實戰

從 .NET 傳統架構到 Python / Cloud-Native AI 平台,我們在 Navi 2.0 用 14 個月時間,讓「自然語言查資料庫」這件事真的能跑在 production。這篇分享從架構設計、Prompt 工程、多雲切換到評估方法的完整實戰心得。


前言:為什麼自己做 Text2SQL?

在企業內部,資料分析的最大痛點不是「資料不夠」,而是「會 SQL 的人太少」。產品端、業務端、客服端每天有大量的「我想知道某 X 的資料」需求,最後都變成工程師排隊改 ad-hoc query。

我們在蓋亞做 Navi 2.0 時,核心目標就是讓任何一個業務同事都能用中文問問題,系統自己生 SQL、查資料庫、回應結果

市面上有很多 Text2SQL 解決方案(LangChain SQLDatabaseChain、Vanna、DataHerald 等),但跑下來都遇到同樣的問題:

  1. 跨資料庫支援不一致——MySQL 跟 MSSQL 的方言、保留字、JOIN 語法不同
  2. Schema 太大會 token 爆炸——直接把 schema 全塞給 LLM,動輒 50K+ token
  3. 準確率不穩——同一個問題第一次答對,第二次答錯
  4. 單一 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 處理

直接把 schema 全塞給 LLM 是新手最常犯的錯誤。我們的資料庫有 200+ 表,full schema 大約 60K token,每次查詢都送一份成本完全不可控。

我們的做法:Schema-as-Vector

  1. 建索引時:把每個表結構轉成自然語言描述,用 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
    
  2. 查詢時:把使用者問題編碼,從 vector DB 取最相關的 5-10 個表

  3. 動態組裝 prompt:只把這些表的 schema 放進 prompt,token 從 60K 降到 3-5K

效果:平均 token 用量降低 92%,且 LLM 不會被無關 schema 干擾。


核心挑戰二:Prompt Engineering

Text2SQL 不是「丟個問題給 LLM」就有答案,prompt 設計細節直接決定準確率。

Few-Shot Example 動態選擇

我們維護一個範例庫(約 200 個 question-SQL pair),每次查詢時:

  1. 計算使用者問題與每個範例的 embedding 相似度
  2. 取 top-5 最相似的範例放進 prompt
  3. 確保 5 個範例涵蓋不同的 SQL 模式(JOIN、聚合、子查詢、視窗函數)

CoT (Chain-of-Thought) Prompt 結構

你是一位資料庫專家。請根據以下 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 切換策略

只用單一 LLM 有三大風險:

  • 單點故障(2024 年 GPT-4 曾連續 4 小時 outage)
  • 成本綁死(供應商漲價只能吞)
  • 能力差異(某些 query 用 Claude 表現更好)

Router 設計

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")  # 預設

Fallback 機制

主 LLM 失敗(timeout / rate limit / API 錯誤)時,自動切到備援:

Azure OpenAI → AWS Bedrock → GCP Vertex AI → 降級到 cached response

每次切換都記錄到 OpenTelemetry,方便 SRE 監控。

注意:Bedrock 與 Vertex AI 在我們系統中還在 POC 階段,production 主力仍是 Azure OpenAI。多雲是「備援與成本優化」的策略,不是「同時三家全跑」。


核心挑戰四:SQL 驗證與安全

LLM 生出的 SQL 絕對不能直接執行,有三大風險:

  1. SQL injection(LLM 可能被使用者問題注入)
  2. 不可逆操作(LLM 寫出 DELETE / UPDATE / DROP)
  3. 效能殺手(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。


Evaluation:科學地測準確率

很多人做完 Text2SQL 就說「我覺得蠻準的」,但「覺得」不是工程。

Golden Set 建立

我們花 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 不同)

CI 整合

每次改 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。


踩過的坑

坑 1:LLM 幻覺出不存在的欄位

症狀:LLM 「腦補」了一個 customer_email 欄位,但實際表只有 email 對策:Schema 驗證階段直接 reject,並把錯誤欄位 + 正確欄位列表餵回去重生

坑 2:大 schema 的 token 爆炸

症狀:200+ 表的 schema 全塞,每次查詢 60K token,成本一個月燒掉幾萬美元 對策:Schema-as-Vector(上面講過)

坑 3:Prompt 版本管理失控

症狀:三個工程師同時改 prompt,沒人知道哪版的準確率最高 對策:把 prompt 當成 code 管理,寫入 Git,每版跑 Golden Set,結果存到 metric DB

坑 4:多語言混雜

症狀:使用者問「2024 年第一季的 revenue」,LLM 把「revenue」當英文不翻譯,但 SQL 欄位是 總營收 對策:Schema 描述同時用中英雙語標記,並在 system prompt 提醒「術語對照表」


結語:AI-Driven Development 的新體感

這個專案的另一層收穫,是全程使用 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 怎麼做?

  1. 在你的 GitHub 建一個 repo,例如 text2sql-engineering-notes
  2. 把這篇 .md 命名為 README.mdtext2sql-from-zero.md
  3. 加上架構圖(可以用 mermaid 重畫,或上傳 PNG)
  4. Push 上去
  5. 重點:在你的 GitHub Profile README 裡 pin 這個 repo

或者更進階的做法:用 GitHub Pages 做 blog

  • Repo 建 your-username.github.io
  • 用 Jekyll、Hugo、Astro 任一框架
  • 把這篇放進 _posts/content/posts/
  • 自動生成 blog 網站

有興趣討論的話?