跳转至

Ch 43 语义查询规划器:Steiner 树与代数改写

面包屑

本书主页Part VII Data+AI 转型 › Ch 43

项目第 4 年 · Data+AI 转型期——查询规划器攻坚


本章你将学到

  • NL2SQL 技术谱系:从 prompt-only 到语义中间层
  • KMB Steiner 树求最小代价 join 子图(含目标函数、KMB 四步、networkx 伪代码、定量评估、近似比实证、join cost 设计、空树降级)
  • 三种代数改写:鸿沟陷阱/冗余连接/存在语义(含 SQL before/after)
  • LLM 幻觉分类学与针对性应对

这一章是全书技术难度最高的一章——它讲的是"AI 怎么知道该 join 哪些表"。

Agentic BI 建设初期,这个问题让我吃了大亏。最初版本 NL2SQL 流程很简单:RAG 检索表结构 → LLM 生成 SQL → 执行。简单查询("上个月总处方量")还行,但用户问复杂问题——"华东区 CardioBrand-A 上月处方量按医院等级分布"——LLM 经常 join 错。要么 join 多余的表,扇出;要么 join 顺序不对,掉进鸿沟陷阱(重复计数);要么漏掉必须的维度表。

根子在于:join 路径选择不是"文本生成"能解决的问题,它是个图论问题。数仓里的表间关系本来就构成图——用户问题涉及的表是节点,连接它们的最优路径就是 Steiner 树。让 LLM "猜" join,不如用算法算出来。

想通这一点后,我决定在 LLM 生成 SQL 之前塞一个语义查询规划器:先用图论算法算出最优 join 子图,再让 LLM 在这个已规划好的路径上写 SQL。这把 NL2SQL 的可靠性抬了一个数量级。

引申:为什么 join 是 NL2SQL 的最大难点?

自然语言里,"join"对应的是关系推理——用户说"华东区 CardioBrand-A 的处方量",AI 得推出:处方量在事实表,区域和药品在维度表,三者靠外键关联。这对人来说很直觉,但对 LLM 极难——它需要同时消化表结构、外键关系和业务语义。而且 join 路径不止一条(直接 join 或过中间表),选错路径轻则性能差,重则结果错误。这就是为什么我们拿图论算法来解 join 路径选择,而不是靠 LLM 推理。


43.1 NL2SQL 技术谱系

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
 subgraph NL2SQL谱系["NL2SQL 技术四阶段"]
 S1@{ icon: "codicon:sparkle", form: "rounded", label: Prompt-only<br/>直接让 LLM 生成, pos: "b", h: 36 }
 S2@{ icon: "codicon:search", form: "rounded", label: Schema-aware<br/>检索表结构辅助, pos: "b", h: 36 }
 S3@{ icon: "codicon:sparkle", form: "rounded", label: 约束解码<br/>约束 LLM 只生成合法 token, pos: "b", h: 36 }
 S4@{ icon: "codicon:graph", form: "rounded", label: 语义中间层<br/>Steiner 树规划+代数改写<br/>NewtonData 方案, pos: "b", h: 36 }
 end

 S1 --> S2 --> S3 --> S4

 class S1,S2,S3 bpProcess
 class S4 bpSuccess
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-1 NL2SQL 技术谱系

阶段 方法 局限
Prompt-only "根据这个问题写 SQL" 幻觉列、错误 join
Schema-aware 检索表结构放入 prompt 术语歧义、鸿沟陷阱
约束解码 约束 LLM 只用合法列名 不解决 join 路径问题
语义中间层 规划器先算 join 子图+改写,再约束生成 复杂但可靠

表 43-1 NL2SQL 技术谱系


43.2 KMB Steiner 树求最小代价 join 子图

问题

用户问"华东区 CardioBrand-A 上月处方量趋势"——涉及 dim_hospital(区域)、dim_product(药品)、fact_prescription(处方事实)、dim_date(日期)。数仓中可能有多条 join 路径连接这些表,规划器需要找到最小代价的 join 子图。

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
 subgraph join路径问题["Join 路径选择问题"]
 T1[dim_hospital] -.->|路径1|T2[dim_product]
 T1 -->|路径2|T3[fact_prescription]
 T2 -->|路径2|T3
 T3 --> T4[dim_date]

 Q[问题涉及 4 张表<br/>可能多条 join 路径<br/>选哪条代价最小?]
 end

 class T1,T2,T3,T4 bpData
 class Q bpDecision
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-2 问题

KMB Steiner 树算法

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
 subgraph Steiner树求解["KMB Steiner 树求解流程"]
 G[表关系图<br/>节点=表 边=join关系] --> TERM[终端节点<br/>问题涉及的表]
 TERM --> KMB[KMB 算法<br/>求最小代价 Steiner 树]
 KMB --> SUB[最小 join 子图<br/>只包含必要表和连接]
 end

 class G,TERM bpProcess
 class KMB bpProcess
 class SUB bpSuccess
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-3 KMB Steiner 树算法

概念 说明
表关系图 整个数仓的表间 join 关系,建为图(节点=表,边=join,权重=join 代价)
终端节点 用户问题涉及的表(如 4 张表)
Steiner 树 连接所有终端节点的最小代价子树
KMB 算法 经典近似算法,求 Steiner 树

表 43-2 KMB Steiner 树算法

引申

Steiner 树是图论里的经典 NP-hard 问题:给定图中一组"终端节点",找到连接它们的最小代价子树。KMB(Kou-Markowsky-Berman)算法是常用的近似算法。落到 NL2SQL 里,"终端节点"就是问题涉及的表,"Steiner 树"就是最优 join 路径。把图论算法搬进 NL2SQL,是 NewtonData 的一个关键决策——join 路径选择从此从"LLM 猜"变成了"算法算"。

把 Steiner 树问题形式化:给定表关系图 G=(V,E)(节点=表,边=join,权重=join 代价)和终端节点集 T⊆V(问题涉及的表),目标是找到连接所有终端节点的最小代价子树:

目标函数:  min  Σ  w(e)
            T'⊆E
约束:    T' 连通所有终端节点(T 中任意两节点在 T' 中有路径)

复杂度:  NP-hard(精确解不可行,用 KMB 近似算法,近似比 2(1-1/|T|))

KMB 算法四步——度量闭包 → MST → 替换边 → 重建子树:

步骤 操作 目的
① 度量闭包 对终端节点集 T 求两两最短路径,得到完全图 G' 把"多跳可达"压缩为"直接相连"
② 求 MST G' 上求最小生成树 M 得到终端节点间的最小代价连接骨架
③ 替换边 M 中的每条边替换回原图中的最短路径 展开为原图上的子图
④ 重建子树 在展开的子图上再求一次 MST,删去非终端的叶子节点 得到最终 Steiner 树

表 43-3 KMB Steiner 树算法

落到 Python,用 networkx 的近似实现一行就能调用,关键是先把语义资产里的 Join 资产(Ch 40)构建成带权图:

# 示意:用 networkx 求 Steiner 树(join 路径规划)
import networkx as nx
from networkx.algorithms.approximation import steiner_tree

def plan_joins(join_assets: list, terminal_tables: list[str]) -> list:
    # 核心意图:把 Join 资产构建成带权图,求连接终端表的最小代价子树
    G = nx.Graph()
    for j in join_assets:
        G.add_edge(j.left_table, j.right_table, weight=j.cost)   # 边权=join 代价
    sub = steiner_tree(G, terminal_tables)                        # KMB 近似算法
    return [(u, v, d["weight"]) for u, v, d in sub.edges(data=True)]
# 例:terminal=["dim_hospital","dim_product","fact_prescription","dim_date"]
# → 返回最小 join 子图(可能含中间表,自动排除不必要表)

定量评估:Steiner 树引入前后的准确率对比

"把 join 路径选择从 LLM 猜变为算法算"是全书最重要的技术声明,必须有数据支撑。以下是 NewtonData 引入 Steiner 树规划器前后的端到端准确率对比(数据按比例缩放自真实评测,规模量级保留):

查询类型 引入前(LLM 猜 join) 引入后(Steiner 树规划) 提升
简单查询(单表/2 表 join) 92% 95% +3%(简单场景 LLM 已够好,提升有限)
复杂查询(3+ 表 join + 聚合) 73% 85% +12%(核心提升,消除鸿沟陷阱/错误 join)
含桥接表的查询(需中间表) 58% 82% +24%(LLM 几乎猜不对中间表,算法精确计算)

表 43-4 定量评估:Steiner 树引入前后的准确率对比

引入 Steiner 树后,复杂查询准确率提升了 12 个百分点——这 12 个百分点正好对应"鸿沟陷阱"和"错误 join 条件"两类失败被消除的量。含桥接表的查询提升更大(24%),因为 LLM 几乎不可能猜对"需要经过哪张中间表才能连接两张表"。

KMB 近似比的实证

KMB 算法的理论近似比是 2(1-1/|T|)(|T| 是终端节点数)。实际测试中,KMB 的表现远好于理论上界——因为真实数仓的表关系图不是最坏情况:

终端节点数 |T| 理论近似比上界 实测平均近似比 说明
2 1.0(精确) 1.0 两表 join 无近似误差
3 1.33 1.05 实测接近最优
4 1.5 1.08 真实图稀疏,远好于上界
5 1.6 1.12 仍远优于理论上界

表 43-5 KMB 近似比的实证

引申

KMB 实测表现远好于理论上界,原因很简单:真实数仓的表关系图是稀疏图——表间 join 关系有限,不是理论最坏的那种稠密图。这意味着 KMB 在实践中几乎等价于最优解,近似误差可以忽略。这也是我们选 KMB 而非更复杂的精确算法的原因——性价比最高。

join cost function 的设计

Steiner 树的边权(join 代价)怎么定?这是规划器准确性的关键。我们综合四种信号加权计算 join cost:

信号 来源 权重 说明
join 输出行数 查询历史统计 0.4 行数越多代价越大(主信号)
FK 基数 Glue Catalog 元数据 0.3 一对多关系基数高则代价大
手动标注 语义资产 Join 资产 0.2 人工标注的高风险 join(如桥接表)
查询历史频率 审计日志 0.1 常用 join 路径略降权(缓存可能命中)

表 43-6 join cost function 的设计

引申:基石回扣——Steiner 树的图 = Kimball 星型模型

Steiner 树操作的"表关系图",本质上就是 Ch 8 建好的 Kimball 星型模型——事实表(fact)居中,维度表(dim)环绕,靠外键关联。Steiner 树的终端节点(terminal nodes)就是查询涉及的事实表和维度表,边是 join 路径,边权是 join 代价。

四种 cost 信号都不是凭空来的:FK 基数来自 Ch 20 的 Glue Catalog 元数据;手动标注来自 Ch 40 的 Join 语义资产(cost: 1.0);查询历史来自审计日志(Ch 11 batch_id 可追溯)。Steiner 树不是现搭的图——它消费的是 CDP 平台前三年的元数据积累。AI 规划器站在数据平台的肩膀上。

# 示意:join cost 加权计算
def join_cost(join_asset, stats) -> float:
    # 核心意图:四信号加权,行数为主,人工标注兜底
    return (0.4 * normalize(stats.avg_output_rows(join_asset))
          + 0.3 * normalize(stats.fk_cardinality(join_asset))
          + 0.2 * join_asset.manual_cost        # 语义资产人工标注
          + 0.1 * (1 - normalize(stats.query_frequency(join_asset))))  # 高频略降权

空 Steiner 树降级策略

并非所有查询都能找到 join 路径——当终端表之间在表关系图里不连通时,KMB 返回"空 Steiner 树"。此时不能默默返回错误,要分情况降级:

场景 降级策略 说明
表间确实无关系 提示用户"这些表之间无 join 路径,请确认问题" 坦诚告知,不硬连
关系未在语义资产声明 提示"缺少 X-Y 的 join 定义,请联系数据团队补充" 引导补充元数据
紧急场景需出数 退化为全连接 + 护栏严格校验(结果可能错误,标注警告) 仅低风险场景,结果带"可能不准确"标注

表 43-7 空 Steiner 树降级策略

# 示意:空 Steiner 树降级
def plan_or_degrade(G, terminals, question):
    if not nx.is_connected(G.subgraph(terminals)):
        # 核心意图:表间不连通,分情况降级而非静默失败
        missing = find_missing_joins(G, terminals)
        if missing:
            return Heal(f"缺少 join 定义:{missing},请联系数据团队补充语义资产")
        return Reject(f"表 {terminals} 之间无 join 路径,请确认问题涉及的表是否正确")
    return steiner_tree(G, terminals)

43.3 三种代数改写

规划器在确定 join 子图后,还要做三种代数改写来保证 SQL 正确性:

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
 subgraph 三种代数改写["三种代数改写"]
 R1[① 鸿沟陷阱改写<br/>聚合前置<br/>先聚合再 join]
 R2[② 冗余连接消除<br/>去掉不改变结果的 join]
 R3[③ 存在语义改写<br/>EXISTS 替代 DISTINCT+JOIN]
 end

 class R1 bpError
 class R2 bpProcess
 class R3 bpInfo
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-4 三种代数改写

① 鸿沟陷阱(Chasm Trap)改写

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
 subgraph 鸿沟陷阱["鸿沟陷阱问题"]
 WRONG@{ icon: "codicon:error", form: "rounded", label: 错误:先 join 再聚合<br/>一对多关系导致重复计数<br/>SUM 被放大, pos: "b", h: 36 }
 RIGHT[正确:先聚合再 join<br/>每层先聚合到正确粒度<br/>再 join 汇总]
 end

 WRONG -.->|改写|RIGHT

 class WRONG bpError
 class RIGHT bpSuccess
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-5 ① 鸿沟陷阱(Chasm Trap)改写

错误写法 正确写法
JOIN fact → SUM 子查询先 SUM → 再 JOIN
一对多导致重复 聚合前置避免重复

表 43-8 ① 鸿沟陷阱(Chasm Trap)改写

-- 错误:先 join 再聚合——一对多关系导致 SUM 被 fan-out 放大
SELECT h.region, SUM(p.prescription_qty) AS total_qty
FROM dim_hospital h
JOIN fact_prescription p ON h.hospital_id = p.hospital_id
JOIN dim_product d ON p.product_id = d.product_id
WHERE d.product_name = 'CardioBrand-A'
GROUP BY h.region;
-- 问题:一家医院多次处方 → prescription_qty 被重复累加

-- 正确:先在子查询聚合到正确粒度,再 join 汇总
SELECT h.region, agg.total_qty
FROM dim_hospital h
JOIN (
    SELECT p.hospital_id, SUM(p.prescription_qty) AS total_qty   -- 聚合前置
    FROM fact_prescription p
    JOIN dim_product d ON p.product_id = d.product_id
    WHERE d.product_name = 'CardioBrand-A'
    GROUP BY p.hospital_id
) agg ON h.hospital_id = agg.hospital_id;

② 冗余连接消除

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
 subgraph 冗余连接["冗余连接消除"]
 BEFORE@{ icon: "codicon:search", form: "rounded", label: join 了 dim_date<br/>但查询不涉及日期维度, pos: "b", h: 36 }
 AFTER[去掉 dim_date join<br/>结果不变但更快]
 end

 class BEFORE bpError
 class AFTER bpSuccess
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-6 ② 冗余连接消除

-- 冗余:join 了 dim_date 但查询不涉及任何日期维度,徒增扫描量
SELECT h.region, SUM(p.prescription_qty)
FROM fact_prescription p
JOIN dim_hospital h ON p.hospital_id = h.hospital_id
JOIN dim_date dt ON p.date_id = dt.date_id    -- 冗余:未引用 dt 任何列
WHERE dt.year = 2026
GROUP BY h.region;

-- 消除:把过滤条件下推到 fact 表的日期列,去掉 dim_date join
SELECT h.region, SUM(p.prescription_qty)
FROM fact_prescription p
JOIN dim_hospital h ON p.hospital_id = h.hospital_id
WHERE p.date_id BETWEEN '2026-01-01' AND '2026-12-31'   -- 下推,无需 join
GROUP BY h.region;

③ 存在语义改写

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
 subgraph 存在语义["存在语义改写"]
 BEFORE[SELECT DISTINCT x FROM a JOIN b<br/>只为过滤 a 中有 b 的行]
 AFTER["SELECT x FROM a WHERE EXISTS<br/>(SELECT 1 FROM b WHERE ...)"]
 end

 class BEFORE bpError
 class AFTER bpSuccess
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-7 ③ 存在语义改写

-- 冗余:用 JOIN + DISTINCT 只为过滤"有采购记录的医院",DISTINCT 抹掉了 join 的 fan-out
SELECT DISTINCT h.hospital_name
FROM dim_hospital h
JOIN fact_procurement f ON h.hospital_id = f.hospital_id
WHERE h.region = 'East China';

-- 改写:用 EXISTS 表达"存在"语义,避免 fan-out 和 DISTINCT
SELECT h.hospital_name
FROM dim_hospital h
WHERE h.region = 'East China'
  AND EXISTS (SELECT 1 FROM fact_procurement f WHERE f.hospital_id = h.hospital_id);

Trade-off

代数改写保证 SQL 的正确性和性能,代价是规划器复杂度大幅上升。对简单查询(单表 + 简单聚合),代数改写的投入不划算;但对复杂分析查询(多表 join + 聚合),代数改写是避免鸿沟陷阱的必需品。规划器通过 Router 判断查询复杂度,只对复杂查询启用改写。

LLM 幻觉分类学与应对

规划器解决了 join 路径,但 LLM 仍会在其他环节产生幻觉。不同类型的幻觉需要不同的应对手段——一刀切地"重试"效果有限,必须分类治理:

幻觉类型 表现 根因 应对手段
幻觉列 生成不存在的 column(如 sales_region LLM 凭语义猜测列名 schema-aware RAG 兜底 + Ch 44 AST 列白名单 + 自愈纠错
幻觉值 用了不存在的 product_id / region 值 LLM 凭印象编造枚举值 L2 术语绑定("华东区"→region='East China')+ 护栏数据层校验
幻觉 join 条件 用错误的 FK 关系连表 LLM 猜外键 Steiner 树元数据契约约束(只允许语义资产定义的 join)+ 护栏 join 层校验
幻觉聚合逻辑 该用 SUM 却用了 AVG / 漏掉 WHERE LLM 误解业务口径 L3 规则约束(GMV 定义强制 WHERE status='completed')+ few-shot 注入 + 护栏语义层

表 43-9 LLM 幻觉分类学与应对

# 示意:幻觉分类应对——按类型走不同自愈路径
def classify_and_heal(error: str, state: AgentState) -> dict:
    # 核心意图:分类幻觉类型,走对应的纠错路径
    if "column" in error and "does not exist" in error:           # 幻觉列
        return heal_via_rag(state, hint="重新检索正确列名")
    if "invalid input syntax" in error or "value too long" in error:  # 幻觉值
        return heal_via_term_binding(state, hint="校准术语→枚举值")
    if "join condition" in error or "cartesian" in error:         # 幻觉 join
        return heal_via_planner(state, hint="用 Steiner 树重算 join 子图")
    return heal_via_rule(state, hint="按 L3 规则重生成聚合逻辑")   # 幻觉聚合

引申

幻觉分类学的价值在于"对症下药"——幻觉列靠检索纠错(成功率最高,90%+),幻觉值靠术语绑定,幻觉 join 靠规划器重算,幻觉聚合靠规则约束。把幻觉从"LLM 的随机错误"变成"可分类、可针对性修复的工程问题",是 NewtonData 自愈成功率做到 ~70% 的关键(详见 Ch 47 基准评测)。


43.4 引申:NL2SQL 的经典陷阱

这三大陷阱不是从论文里学来的——是踩坑踩出来的。做 NL2SQL 的第一年,我每周都会收到业务方的排障工单:"这个数据不对""怎么比上个月多了一倍""这个表为什么 join 不上来"。翻 SQL 一看,十有八九掉进了这三个坑里的一个。后来我把这些 case 攒起来分类,发现 80% 的错误集中在三种模式——这就是下面的"三大经典陷阱"。

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
 subgraph 经典陷阱["NL2SQL 三大经典陷阱"]
 T1[鸿沟陷阱 Chasm Trap<br/>一对多 join 后聚合<br/>导致重复计数]
 T2[扇出陷阱 Fan-out Trap<br/>多对多 join<br/>导致行数膨胀]
 T3@{ icon: "codicon:sparkle", form: "rounded", label: 幻觉列 Hallucination<br/>LLM 生成不存在的列名<br/>或错误表名, pos: "b", h: 36 }
 end

 class T1,T2,T3 bpError
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpGroup fill:#ffffff,stroke:#0f62fe,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px

图 43-8 引申:NL2SQL 的经典陷阱

陷阱 原因 NewtonData 的应对
鸿沟陷阱 先 join 后聚合导致重复 代数改写①:聚合前置
扇出陷阱 多对多 join 导致膨胀 代数改写②:冗余连接消除
幻觉列 LLM 生成不存在的列 R 引擎精确匹配 + AST 护栏白名单

表 43-10 引申:NL2SQL 的经典陷阱

还有一个隐藏陷阱值得注意——时间语义陷阱。用户问"上月",LLM 得算出"2026-05-01 到 2026-05-31",但 LLM 经常算错边界(比如把"上月"理解为"过去 30 天"而不是"上一个自然月")。这个陷阱不在 join 层面,在语义理解层面——靠 L3 业务规则约束(定义"上月=上一个自然月")+ few-shot 注入来应对。

Trade-off

三大陷阱(+ 时间语义)是 NL2SQL 生产化的"必考题"。NewtonData 的"规划器先算 + 护栏后验 + 分类自愈"三板斧把这些问题从 LLM 的责任变成了工程系统的责任。但代价也明显:系统复杂度上升、端到端延迟增加。对简单查询(单表 + 简单聚合),这套方案"杀鸡用牛刀";但对复杂分析查询(3+ 表 join + 聚合 + 时间过滤),它是保证正确性的唯一可靠手段。NL2SQL 的工程化不是为了让 LLM 更聪明,而是为了让 LLM 的错误可预测、可分类、可修复。


本章小结

  • NL2SQL 四阶段:Prompt-only → Schema-aware → 约束解码 → 语义中间层(规划器+改写)
  • KMB Steiner 树:把"join 路径选择"从"LLM 猜"变为"图论算法算"——目标函数 min Σ w(e)(NP-hard,KMB 近似,四步:度量闭包→MST→替换边→重建子树),networkx 一行调用
  • 三种代数改写:鸿沟陷阱(聚合前置,SQL before/after)/ 冗余连接消除(条件下推去 join)/ 存在语义(EXISTS 替代 DISTINCT+JOIN)
  • LLM 幻觉分类学:幻觉列(检索纠错)/ 幻觉值(术语绑定)/ 幻觉 join(Steiner 重算)/ 幻觉聚合(规则约束)——分类治理使自愈成功率 ~70%
  • 三大经典陷阱:鸿沟陷阱(重复计数)/ 扇出陷阱(行数膨胀)/ 幻觉列(不存在的列名)——NewtonData 用规划器+护栏分别应对

下一章

Ch 44 五层 SQL 护栏与执行安全 —— SQL 生成好了,怎么保证它安全可执行?接下来看五层护栏设计。

评论