
本文是一篇完整的技术设计方案复盘:如何从零搭建一个「上传一张数据表 → 用自然语言提问 → 得到确定、安全、可解释的查询结果」的数据问答(NL2SQL)系统。它脱胎于一个真实上线的商用产品功能(数据洞察/问数),我会把架构、核心设计思想、关键代码片段、踩过的坑和未来的演进方向都摊开讲清楚。文末会给出落地产品入口,欢迎体验后一起交流。一、为什么「LLM 直接生成 SQL」这条路走不通很多人做 NL2SQL 的第一反应是:把表结构塞进 prompt,让大模型直接输出 SELECT 语句,再拿去执行。这个方案能跑通 Demo,但上不了生产。原因有三个,而且每一个都是致命的:1. 幻觉(Hallucination)大模型可能「脑补」出表里根本不存在的字段。比如表里只有销售额,它却输出:SELECTSUM(profit)FROMsales;-- 表里根本没有 profit 列SQL 一执行就报错,用户看到的是「系统错误」,体验极差。2. 注入(Injection)用户问句里有攻击性内容时,大模型「忠实地」把用户输入拼进了 SQL:-- 用户问:「销量等于 1 OR 1=1 的记录」SELECT*FROMtWHEREcol=1OR1=1;-- 直接绕过所有过滤即便不聊恶意,用户数据里一个普通的引号、反斜杠,都可能导致 SQL 语法错误或注入。3. 语义不稳定(Non-deterministic)同一个问题,大模型这次生成GROUP BY 地区,下次生成GROUP BY region;这次用SUM,下次用TOTAL。结果不可复现,难以测试、难以调试、难以向用户解释「为什么是这个答案」。核心结论:大模型擅长「理解自然语言」,不擅长「精确执行」。所以我们把这两件事彻底拆开,各司其职。二、核心设计:LLM 负责「理解」,代码负责「执行」整个系统的指导思想只有一句话:大模型只产出「结构化意图」,不碰 SQL;SQL 由确定性代码生成。也就是一条两段式管线:自然语言问题 │ ▼ ┌─────────────────────────────┐ │ ① 意图识别(LLM) │ 大模型唯一的工作:把问题翻译成 │ Question ──► QueryIntent │ 一个结构化 JSON「意图」 └─────────────────────────────┘ │ QueryIntent(结构化 JSON) ▼ ┌─────────────────────────────┐ │ ② 确定性 SQL 生成(纯代码) │ 不调用 LLM,纯 Java/代码逻辑 │ QueryIntent ──► SQL + 参数│ 白名单 + 参数化,杜绝注入与幻觉 └─────────────────────────────┘ │ ▼ 执行查询,返回结果为什么这样设计是「正确的分工」:环节由谁做为什么理解用户意图LLM自然语言理解是它的强项业务语义 → 物理列映射本体(Ontology)这是核心资产,见下文生成 SQL确定性代码可测试、可审计、可复现安全(防注入)参数化 + 白名单交给代码,不赌 LLM 的「自觉」这里出现了一个关键词:本体(Ontology)。它是整套方案真正的护城河,也是下一节的主角。三、整体架构先看一张总览图,再逐个模块拆解:┌──────────────────────────────────────────────┐ │ 前端(/insights) │ │ 上传 CSV/Excel · 问数 · 查看本体 · 历史 │ └──────────────────────┬───────────────────────┘ │ HTTP REST ┌──────────────────────▼───────────────────────┐ │ AskController │ │ /upload /query /datasets /dataset/{id} │ │ /history /dataset/{id}/ontology │ └──────────────────────┬───────────────────────┘ │ ┌──────────────────────────────────┼───────────────────────────────┐ │ AskService(编排层) │ │ upload: 解析 → 建本体 → 建表 → 入库 │ │ query : 计费校验 → 意图识别 → SQL → 执行 → 扣费 → 记历史 │ └───────┬───────────────────┬──────────────────┬───────────────────┘ │ │ │ ┌───────▼────────┐ ┌───────▼────────┐ ┌──────▼─────────────┐ │ 解析器 │ │ 本体构建/增强 │ │ 意图识别 + SQL │ │ CSV(自研状态机) │ │ OntologyBuilder│ │ IntentRecognizer │ │ Excel(POI) │ │ OntologyEnricher│ │ SqlResolver│ └───────┬────────┘ └───────┬────────┘ └──────┬─────────────┘ │ │ (Enricher 调 LLM)│ (Resolver 不调 LLM) └───────────────────┴───────────────────┘ │ ┌──────────────────────▼───────────────────────┐ │ PostgreSQL(每租户独立 schema) │ │ ask_dataset(元数据) · ask_message(历史) │ │ tenant_md5.ask_data_id(物理数据表) │ └──────────────────────────────────────────────┘一句话概括数据流:用户上传一张表 → 系统解析出Schema(表头 + 类型)→ 建一张物理表存数据。系统基于Schema自动构建本体,再用 LLM增强语义。用户提问 → LLM 产出QueryIntent→ 代码把意图翻译成参数化 SQL→ 执行 → 返回。下面按这条链路,从 Step 1 讲起。四、Step 1:数据接入与自动建模4.1 支持什么输入商用场景下,用户手里的「表」大概率是CSV或Excel。两个格式都要接:CSV:不依赖第三方库,手写了一个 RFC4180 风格的状态机解析器,正确支持「字段内逗号 / 引号 / 换行 / 转义引号」。编码上UTF-8 优先,失败自动回退 GBK——这是中文环境下几乎必踩的坑,Excel 导出的 CSV 很多是 GBK 编码。Excel(.xlsx / .xls):用 Apache POI,只读第一个 sheet,第一行当表头,支持公式求值、日期格式识别。4.2 自动类型推断与物理列命名解析出「表头 + 数据行」后,统一交给一个公共模块做三件事:行数上限:单表最多200,000行(防资源耗尽)。类型推断:对每列采样前 200 行,判断是NUMBER/DATE/TEXT。全部能parseDouble→NUMBER全部匹配日期正则(yyyy-MM-dd等)→DATE否则 →TEXT物理列命名:把表头原文当作「展示名」,同时生成一组物理列名c0, c1, c2 ...。为什么要引入c0/c1这种物理列?这是整套安全设计的地基——物理列名是系统生成、用户永远不可见、也永远不被拼进用户输入的。后面所有 SQL 都引用c0/c1,用户输入只出现在参数?里。// Schema 就是「展示名 → 物理列 → 类型」的清单classSchema{ListColumncolumns;classColumn{Stringname;// 展示名:表头原文,如「销售额」Stringcolumn;// 物理列:c0, c1, ...ColumnTypetype;// NUMBER / DATE / TEXT}}