数据库语义网关DatI:用Java构建自然语言查询到SQL的智能翻译层
2026/9/18 5:16:59 网站建设 项目流程

说实话,做数据查询这块做了快十年,我越来越觉得传统的 BI 工具有点“重”。业务方想要一个指标,先得找数据团队提需求,然后等排期、写 SQL、做报表,最后交付的时候业务可能已经改需求了。这中间浪费的时间,远比查数本身多得多。

后来 Agent 火了以后,我一直在想一个问题:既然大模型能理解自然语言,能不能让它替我们把数据库里的数查了?但真正尝试之后发现,事情没那么简单。LLM 再聪明,它也不了解你的库表结构、字段含义、指标口径,直接让它写 SQL 很容易翻车。于是我开始动手做了一个轻量级的数据库语义网关,取名 DatI——就是在数据库和大模型/BI 之间加的一层“翻译官”,把复杂的数据结构和业务逻辑,转换成模型能理解的语义描述,再把自然语言问题安全地翻译成 SQL 执行。

这篇文章就把我做 DatI 的思路、核心代码、踩过的坑完整分享出来,如果你也在做类似的数据智能产品,或者正在纠结怎么让 Agent 安全地查询数据库,这篇文章应该能帮你省不少时间。

1. 为什么需要“数据库语义网关”:从 BI 到 Agent 的视角转变

1.1 BI 时代的日常:报表工具与 SQL 拼装

过去做 BI,最常见的工作流是:业务提需求 -> 数据工程师写 SQL -> 定时跑批 -> 报表展示。这个过程有几个老问题,几乎每个团队都遇到过:

  • 指标口径不统一。财务说的“收入”和运营说的“收入”,统计范围可能完全不同,但底层查的是同一张订单表。
  • SQL 复用难。每次新需求都要重新写一段 SQL,哪怕只是换个时间维度,有些同事甚至要复制粘贴再改半天。
  • 响应速度慢。从提需求到拿到数据,最短也要一两天,遇到紧急决策根本来不及。

所以传统 BI 的本质,其实是“人去找数据”,整个人都要围着报表工具转。

1.2 Agent 时代的变化:从“人写 SQL”到“机器写 SQL”

Agent 带来一个根本性的变化:用户不再需要关心 SQL 怎么写,只需要用自然语言描述“我想知道什么”。这听起来很美好,但实际落地时,你会遇到几个残酷的现实:

  • 大模型没看过你的表结构。它不知道t_ordert_order_detail哪个是主表,不知道status=1代表什么。
  • 大模型不了解业务口径。同样的“活跃用户”,不同团队有不同定义,模型无法自己判断该用哪个口径。
  • 大模型不熟悉你的 SQL 方言。即使知道 MySQL 语法,到了 ClickHouse、PostgreSQL,函数差异也很大。

1.3 语义层就是解决这个问题的关键

数据库语义网关的核心,是在数据库和 AI 之间加一层“语义中间层”。它做三件关键的事情:

  1. 把数据库的表结构、字段注释、枚举值、表关系、指标定义统一管理起来,形成一套“语义模型”。
  2. 把语义模型转换成 LLM 能够理解的提示词(比如 Schema 描述、指标说明),让模型“懂业务”。
  3. 把模型生成的 SQL 做合法性校验、权限校验,再交给数据库执行,避免安全事故。

打个比方,语义网关就像一位资深的数据分析师坐在数据库前面,大模型是业务方的嘴,分析师负责听懂需求、判断意图、写出靠谱的 SQL,并把结果安全地拿出来。

2. DatI 整体架构设计与 Java 技术栈选型

2.1 数据流设计

DatI 的整体数据流,从用户提出自然语言问题开始,到最终返回结果集结束,分五步:

  1. 客户端发起请求,携带用户问题 + 会话上下文 + 目标数据源标识。
  2. DatI 根据数据源标识加载对应的语义模型(表、字段、指标、关系、权限规则)。
  3. 语义模型被渲染成结构化的提示词,连同用户问题一起发给 LLM,模型返回候选 SQL 和简短解释。
  4. DatI 对候选 SQL 做语法校验、关键词黑名单过滤、血缘字段映射确认,必要时自动改写。
  5. 校验通过后,放入连接池执行查询,结果集统一封装返回。

这套流程看起来不复杂,但每一步都有不少细节。接下来我按模块拆开讲。

2.2 模块划分:四个核心组件

DatI 我拆成了四个核心组件,各司其职:

  • 语义元数据管理模块(Semantic Metadata Manager):负责表结构扫描、字段注释同步、指标口径配置、关系维护。
  • 提示词构造模块(Prompt Builder):把语义模型渲染成 LLM 友好的描述,同时加入业务规则和 SQL 风格约束。
  • SQL 解析与校验模块(SQL Validator):对 LLM 返回的 SQL 做词法校验、危险语句拦截、方言适配改写。
  • 执行与结果封装模块(Query Executor):通过连接池执行 SQL,把ResultSet转成统一的 JSON 结构。

模块之间通过简单的接口交互,没有引入消息队列,因为这个量级的请求用同步调用就够了,引入 MQ 反而增加运维成本。

2.3 为什么选 Java 而不是 Python

这可能是大家最关心的问题。市面上做 AI 应用的大多用 Python,但 DatI 我坚持用 Java,原因很实在:

  • 团队现有技术栈以 Java 为主,数据库驱动、连接池、权限系统都是现成的,用 Java 接入成本最低。
  • 数据库访问这块,Java 的 JDBC 生态非常成熟,Druid、HikariCP、MyBatis 都有现成方案,稳定性经过大量生产环境验证。
  • 未来如果要接入企业内部的权限系统、监控系统,Java 身份体系更容易融合。
  • 大模型调用层面,现在 Spring AI、LangChain4j 等 Java 生态框架已经很成熟,调用 OpenAI 兼容接口并不比 Python 麻烦。

当然,如果你是一个纯 Python 团队,也完全可以参考这个架构思路用 FastAPI 实现,核心设计是通用的。

3. 核心实现:语义元数据建模与方言转换

3.1 语义模型的数据结构设计

语义模型是整个 DatI 的地基。设计得好不好,直接决定 LLM 生成的 SQL 准不准。我最终采用了“五层模型”:

  • 数据源(DataSource):一个数据库连接配置,包含 JDBC URL、用户名、加密后的密码。
  • 表(Table):对应物理表,记录表名、业务名、业务描述。
  • 字段(Column):对应物理字段,记录字段名、业务名、类型、是否可枚举、备注。
  • 指标(Metric):虚拟字段,比如“销售额”“活跃用户数”,可以映射到一段聚合 SQL 片段。
  • 关系(Relation):表之间的 JOIN 关系,如t_order.user_id = t_user.id,用于告诉 LLM 怎么连表。

为了直观说明,我贴一下核心的实体定义(简化版):

public class SemanticTable { private String tableName; // 物理表名 private String businessName; // 业务名称,如“订单表” private String description; // 业务描述 private List<SemanticColumn> columns; } public class SemanticColumn { private String columnName; // 物理字段 private String businessName; // 业务名称,如“下单用户ID” private String dataType; // JDBC 类型 private String description; // 备注 private boolean enumColumn; // 是否枚举字段 private Map<String, String> enumValues; // 枚举值映射,如 {"0": "待支付"} } public class SemanticRelation { private String leftTable; private String leftColumn; private String rightTable; private String rightColumn; private String joinType; // INNER/LEFT }

这套模型的好处是足够简单,可以把绝大多数 BI 场景的表结构描述清楚。真正复杂的时候(比如星型模型、多级事实表),可以通过增加关系和指标层级来扩展。

3.2 元数据自动扫描与同步

人工维护语义模型不现实,表结构一变,维护工作就失控。所以我写了一个自动扫描器,基于 JDBC 的DatabaseMetaData可以非常方便地拿到表、列、主键、外键信息:

public class MetadataScanner { public List<TableMeta> scan(DataSourceMeta ds) throws SQLException { List<TableMeta> tables = new ArrayList<>(); try (Connection conn = DriverManager.getConnection(ds.getJdbcUrl(), ds.getUsername(), ds.getPassword())) { DatabaseMetaData metaData = conn.getMetaData(); try (ResultSet rs = metaData.getTables(null, ds.getSchema(), "%", new String[]{"TABLE"})) { while (rs.next()) { TableMeta table = new TableMeta(); table.setTableName(rs.getString("TABLE_NAME")); table.setTableType(rs.getString("TABLE_TYPE")); table.setRemarks(rs.getString("REMARKS")); tables.add(table); } } } return tables; } }

但这里有个关键点:字段注释和表注释每个数据库获取方式不一样。MySQL 的DatabaseMetaData.getTables()返回的REMARKS里有注释,但 PostgreSQL 的驱动可能拿不到完整的注释,需要额外查询information_schemaobj_description()函数。还有 ClickHouse 的引擎类型也不一样,要单独适配。

字段级别的注释,我在 MySQL 下一般直接查询:

SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'your_db'

扫描完成后会生成一个“差异对比”环节,对比上一次的元数据快照,把新增字段、删除字段、变更类型用列表展示出来,DBA 可以一键确认同步或忽略。

3.3 SQL 方言转换层:让一套语义模型适配多种数据库

这是 DatI 里很有意思的一部分。LLM 生成的 SQL 通常更符合 MySQL 习惯,但目标库可能是 PostgreSQL 或 ClickHouse,所以方言转换必不可少。

我只处理三类高频差异:

  • 分页语法:MySQL 是LIMIT ? OFFSET ?,PostgreSQL 相同,但 SQL Server 是OFFSET ? ROWS FETCH NEXT ? ROWS ONLY,ClickHouse 也接近 MySQL。
  • 函数差异:日期格式化 MySQL 用DATE_FORMAT,PostgreSQL 用TO_CHAR,ClickHouse 用formatDateTime。这些需要做映射。
  • 引号差异:MySQL 默认用反引号,PostgreSQL 用双引号,ClickHouse 也支持反引号。

转换层的实现我采用了“词法级替换”而非“完整 AST 重写”,原因很简单:完整 AST 重写工作量太大,而实际业务里高频差异就那些,做一个可配置的规则映射表,性价比最高。比如:

public class DialectConverter { private static final Map<String, Map<String, String>> FUNCTION_MAP = Map.of( "mysql", Map.of("DATE_FORMAT", "DATE_FORMAT"), "postgresql", Map.of("DATE_FORMAT", "TO_CHAR") ); public String convert(String sql, String targetDialect) { String converted = sql; // 简单的函数替换,实际逻辑需要处理参数个数和位置 if ("postgresql".equals(targetDialect)) { converted = converted.replaceAll("DATE_FORMAT\\s*\\(([^,]+),\\s*'%Y-%m-%d'\\)", "TO_CHAR($1, 'YYYY-MM-DD')"); } return converted; } }

注意:规则映射一定要做单元测试,尤其是日期格式。我吃过一次亏,MySQL 的%Y-%m-%d转成 PostgreSQL 的YYYY-MM-DD时,漏掉了一个%H:%i:%s的映射,导致一批时间查询在测试环境没有暴露,上了生产才报错。

4. NL2SQL 引擎:从用户问题到可执行 SQL 的完整链路

4.1 API 设计:一个简单但完整的接口

客户端访问 DatI,本质上就是一个接口。我设计了一个 POST 接口:

@RestController @RequestMapping("/api/v1/query") public class QueryController { @PostMapping public Result<QueryResponse> query(@RequestBody QueryRequest request) { return queryService.execute(request); } }

QueryRequest包含三个核心字段:question(用户问题)、dataSourceId(目标数据源)、sessionId(会话 ID,用于多轮对话上下文)。QueryResponse则包含:sql(生成的 SQL)、columns(返回列)、rows(数据行)、executionMs(执行耗时)、confidence(置信度)。

接口返回的 JSON 结构会严格区分“元数据”和“数据”,这样前端渲染表格的时候可以直接绑定列名,不用再做二次解析。

4.2 提示词构造:如何把语义模型变成 LLM 能理解的内容

LLM 不直接看数据库,它只看得懂文字。所以提示词的构造直接决定结果质量。我最开始只是简单地把所有表结构拼成一段文字丢给模型,效果很差。后来总结了一套“三段式提示词”:

  • 角色定义:告诉模型它是数据库语义层的翻译官,只根据提供的表结构回答问题。
  • 语义描述:列出相关的表、字段、枚举值、指标口径、表关系,并刻意强调某些字段不要直接使用。
  • 输出约束:要求模型只输出 SQL 和一句简短解释,不要输出多余内容,禁止使用危险 SQL 关键字。

示例:

你是 DatI 的 SQL 翻译助手。用户通过自然语言查询数据,你只能基于下列表结构生成 SQL,不能臆造不存在的字段。 表结构: - 表 t_order(订单表) - id BIGINT 主键 - user_id BIGINT 用户ID - amount DECIMAL(10,2) 订单金额 - status INT 订单状态,枚举: 0=待支付,1=已支付,2=已取消 - created_at DATETIME 下单时间 关系: - t_order.user_id 关联 t_user.id 规则: - 默认不查询已取消订单(status=2) - 金额统计单位是元 用户问题: 查询上个月的订单总金额 请输出: 1. SQL(不要使用 Markdown 代码块) 2. 简要执行说明

这套提示词在实际测试中,把 SQL 正确率从 60% 左右提升到了 85% 以上。核心在于“不臆造字段”这条约束,配合结构化的枚举值定义,极大减少了模型编列名的情况。

4.3 LLM 返回后的校验与兜底

模型生成 SQL 后,直接执行是非常危险的。DatI 做了三层校验:

  • 黑名单校验:拦截DROPDELETEUPDATEINSERTALTERTRUNCATE等写操作关键字。这里有个技巧,不能简单地用contains判断,因为表名或注释里可能包含类似词汇,必须做“词法切分”后按 token 判断。
  • 列名合法性校验:从 SQL 里解析出所有引用的表和列,检查它们是否都存在于语义模型中。如果模型“发明”了一个字段,直接拒绝执行。
  • 权限校验:根据请求用户的角色,判断是否有权限访问这些表和列。这个很实用,比如普通运营人员只能查t_ordert_user,不能查t_salary

校验不通过的时候,DatI 会自动触发“二次修正”:把校验错误信息反馈给 LLM,让它根据错误修改 SQL,最多重试一次。这个机制很管用,在 80% 的情况下,模型能自行修正问题。

4.4 缓存机制:让常见查询更快

NL2SQL 最耗时的环节其实是 LLM 推理,而不是数据库执行。同一个问题被不同用户反复问,每次都让 LLM 算一遍太浪费。所以我设计了一个两层缓存:

  • SQL 生成缓存:以“数据源ID + 用户问题 + 语义模型版本号”为 key,缓存 LLM 生成的 SQL,有效时间按场景配置,通常是 10-30 分钟。
  • 查询结果缓存:以“SQL + 参数”为 key,缓存数据库查询结果,适合数据变化不频繁的场景(如日级统计数据)。数据变更频繁的业务要谨慎,建议只对聚合查询开启。

缓存的存储我用的是 Caffeine,纯本地缓存,简单高效。分布式场景下可以考虑 Redis,但为保持轻量,DatI 默认本地缓存就够了。

5. 实操过程中的那些坑:类型映射、权限与多轮对话

5.1 数据类型映射和日期格式化的坑

这是我从开发到上线踩得最多的坑。数据库类型和 Java 类型的映射看似简单,实际却容易被忽略。

比如 MySQL 的DECIMAL(10,2)通过 JDBC 拿到之后,默认是BigDecimal,如果直接序列化成 JSON,可能变成12.00这种带小数的格式,前端展示没问题,但用户问的是“订单总金额”,返回一串小数会显得不自然。我处理的方式是配置“精度规则”:对金额字段,默认保留两位小数并补齐;对数量字段,默认去掉多余的零。

日期格式更麻烦。LLM 有时候会生成WHERE created_at >= '2024-01-01',但表里存的是DATETIME类型,这样没问题。可如果数据库时区是 UTC,而业务希望按北京时间统计,查询结果就会偏差 8 小时。我的解决方案是在数据源配置里增加timeZone属性,执行查询前动态拼接SET time_zone = ?,而不是在 JDBC URL 里写死,方便多租户切换。

5.2 权限控制和数据隔离不能省

在语义网关上做权限控制,比在数据库层做要灵活得多。我采用“行级权限 + 列级权限”两层控制:

  • 列级权限:用户在语义模型里未授权的字段,不会出现在提示词里。这样 LLM 根本不会生成包含这些字段的 SQL,比事后拦截更干净。
  • 行级权限:通过追加 SQL 片段实现。比如运营团队的用户只能查seller_id = 10086的数据,DatI 在执行前会把这条条件自动拼接到 SQL 上。字段在配置里叫rowFilter

这里要特别提醒:千万不要让客户端直接把rowFilter传给 SQL 执行层,否则等于把权限控制权交给了用户。正确做法是权限规则配置在 DatI 内部,根据调用方的身份动态获取,不要信任客户端传来的任何权限字段。

5.3 多轮对话:上下文管理的几个思路

Agent 场景下,用户常常会连续追问,比如:“上海地区的销售额是多少?”“那北京呢?”第二句话没有明确指出要查什么,模型必须结合第一轮的语义才能理解。

DatI 的多轮对话有两条路:

  • 轻量方案:把前 N 轮的历史questionsql记录在会话缓存里,构造提示词时一起发给 LLM,让模型参考历史。但这个方案在上下文变长之后,Token 消耗会快速上涨,效果也会下降。
  • 语义压缩方案:每轮结束时,用 LLM 生成一个“本轮对话摘要”(比如“用户想查各地区销售额,当前地区上海,指标是销售额”),下一轮只带摘要,不带完整历史。

我在 DatI 里默认采用语义压缩方案。因为 BI 类查询往往是确定性的,摘要足够表达意图,还能减少 Token 消耗。

5.4 常见问题与排查速查表

现象可能原因处理方式
生成的 SQL 使用了不存在的字段语义模型里字段注释或业务名不充分检查字段描述,增加字段别名,或在提示词中补充“禁止臆造”约束
查询超时表数据量大、缺少索引为语义模型配置查询超时时间,或为常用字段建议加索引
返回的数据类型和预期不符JDBC 类型映射未配置在数据源里配置类型覆盖规则,如 BIGINT 转 Long、DECIMAL 转 BigDecimal
同一条 SQL 在不同库执行结果不同方言差异检查方言转换层的规则映射是否覆盖了函数,尤其是日期函数
权限拦截误伤字段名包含敏感词但业务合法优化黑名单词的 token 判定逻辑,避免朴素 contains 匹配
LLM 返回格式不稳定提示词约束不足严格输出要求,使用 JSON 输出模式,再解析成结构化内容

5.5 Java Agent/Spring AI 的集成经验

如果你在 Java 生态里做 Agent,目前 Spring AI 和 LangChain4j 都比较成熟了。DatI 里我接入的是 Spring AI 的ChatClient,统一封装了模型调用逻辑,好处是未来换模型厂商只需改配置文件,不需要改业务代码。

ChatClient chatClient = ChatClient.builder(chatModel).build(); String prompt = buildPrompt(request.getQuestion(), semanticModel); String response = chatClient.prompt() .user(prompt) .call() .content();

不过有一点我提醒一下:Spring AI 版本迭代很快,API 变动不小,如果项目要长期维护,建议把模型调用封装在自己的一层接口后面,不要直接散落在业务代码里。

6. 轻量化设计的一些取舍思考

好多人问我,你这套东西能做成开源项目吗?其实我本来没打算做一个大而全的平台,DatI 刻意做了很多取舍:

  • 不自己存数据,只做转发和翻译,状态尽量无状态化。这样部署就是一个 Java Jar 包,资源占用很低。
  • 不做调度任务,不碰 ETL。ETL 是数据仓库的职责,DatI 专注在“查询”这一层。
  • 不做可视化。图表展示可以对接已有的 BI 工具或者前端组件,DatI 只提供干净的 JSON 数据。
  • 用户的身份认证直接对接企业已有的 SSO 或内部账号体系,不重复造轮子。

取舍的原则很朴素:凡是“查询”这个动作以外的能力,优先级都往后放。这样才保证了整个系统在接入 AI 能力之后,依然能保持轻量,一个普通 2 核 4G 的实例就能跑得很舒服。

7. 最后在实际落地中的几点体会

这个项目从设计到上线,前后经历了两个多月,真正让我觉得“值得做”的时刻,是业务同学第一次用自然语言问出“上个月华东区退货率最高的商品是哪几个”,然后系统直接返回了表格数据的瞬间。那一刻,你会明显感受到,数据查询的路径从“人找数”变成了“数找人”。

我个人的体会是,数据库语义网关的本质不是做 NLP,而是做知识管理。你投多少精力在语义模型的组织和维护上,NL2SQL 的上限就有多高。模型本身的能力会不断迭代,但一套清晰的语义模型,才是 DatI 真正能持续沉淀下来的资产。

另一个实践建议是,先给核心业务跑一套最小闭环,比如就只接一个订单表、两个维度表,把整个流程跑通,再逐步加表。一次接 200 张表只会让 LLM 昏头转向,查询准确率惨不忍睹。

后续我打算继续扩展的方向有两个:一是让 DatI 能自动从历史查询中学习常见问题和对应的 SQL,沉淀成“查询经验库”;二是接入流式输出能力,让用户在等待 SQL 生成的过程中,实时看到步骤进展。这两块做完,它就不再只是一个数据库翻译工具,而更像一个能逐步自生长的数据助手了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询