ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用自然语言查询SQLite:Text2SQL实战指南

用自然语言查询SQLite:Text2SQL实战指南 做数据的人大概都有过这种经历业务同事笑眯眯地走过来甩出一句“帮我查一下上个月华北区销量前三的产品”然后你就要放下手头的活儿开终端、连数据库、写SQL、跑出结果、再导成Excel发过去。次数多了你肯定想过要是直接让电脑听懂人话自己把SQL写出来该多好。Text2SQL 要解决的就是这件事而把落地场景选在 SQLite 上是性价比最高的起点。这篇文章就从零开始和你聊聊我用自然语言操作 SQLite 数据库的实现思路、核心代码、实测结果和一些折腾了挺久的坑希望能给刚接触 Text2SQL 的朋友一点参考。1. 为什么把 Text2SQL 的落地场景选在 SQLite 上1.1 SQLite 在轻量级数据管理中的独特位置SQLite 可能是世界上最容易被忽视的数据库。它不要求独立的服务进程数据就存在一个普通文件里Python 自带的sqlite3模块就能直接读写连安装都省了。很多桌面软件、移动 App、嵌入式设备的数据存储都压在它身上Flutter 里用sqflite做本地存储底层就是 SQLite。我之前给一个内部工具做过报表模块数据量不大、查询不复杂直接用 SQLite 存业务明细配合 Text2SQL 让同事用中文提问题效果出乎意料地好。正因为 SQLite 够简单它特别适合用来验证 Text2SQL 链路。你不会被连接池、权限体系、网络延迟这些干扰项分心拿到一个.db文件核心精力可以全放在“自然语言到 SQL”的转换逻辑上。等这条链路跑通了再迁移到 MySQL 或 PostgreSQL只需要替换方言适配层整体架构不用动。1.2 自然语言转 SQL 的典型用户画像和需求我在实践中总结下来想用自然语言查数据库的人基本分两类。一类是真正的非技术用户比如运营、销售、财务。他们脑子里有清晰的业务问题但完全不懂 SQL也不想学。他们要的只是“给我一个数”或者“给我一张表”最好在聊天框里输入一句话就能拿到结果。另一类其实是开发自己。天天写重复的SELECT、JOIN、GROUP BY也很烦尤其在临时排查数据问题时自然语言能显著缩短从“想到问题”到“拿到数据”的链路。这两类人有一个共同诉求结果必须可靠。Text2SQL 生成 SQL 看起来好玩真正卡住它落地的永远是准确率。SQL 是精确语言差一个表名、一个字段结果就完全不对差一个条件过滤可能把全表数据扔出来谁也扛不住。所以后面我做的所有设计本质上都在围绕“可控的准确率”做文章。2. 拆解 Text2SQL 的核心工作流从自然语言到可执行 SQL2.1 输入解析与 Schema 建模让模型“认识”你的表Text2SQL 的输入不是一句干巴巴的话而是“问题 数据库结构”。模型再聪明也没法凭空知道你表里有cust_id还是customer_id不知道status字段存的是1/2/3还是active/disabled。所以在把问题发给模型之前先要把数据库的 Schema 抽取出来作为上下文一起送进去。我在项目里写了一个get_schema_info函数用 SQLite 自带的sqlite_master和PRAGMA table_info来拿表结构和字段信息然后把它们拼成一段结构化的文本。比如import sqlite3 def get_schema_info(db_path): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( SELECT name FROM sqlite_master WHERE typetable AND name NOT LIKE sqlite_% ) tables cursor.fetchall() schema_lines [] for (table_name,) in tables: cursor.execute(fPRAGMA table_info({table_name})) columns cursor.fetchall() col_desc , .join([col[1] for col in columns]) schema_lines.append(f表 {table_name} 的字段: {col_desc}) conn.close() return \n.join(schema_lines)这个函数返回的内容类似表 orders 的字段: id, customer_name, region, product, amount, order_date, status 表 products 的字段: id, name, category, price, stock看起来简单但这一步决定了模型能不能生成正确的 SQL。如果你不给 Schema只给一句“查一下上个月销售额”模型只能猜猜错率极高。给全 Schema 之后模型至少知道有哪些表、哪些字段可以选。2.2 SQL 生成策略直接生成还是模板填充拿到 Schema 和用户问题后下一步就是生成 SQL。市面上的方案大致分两派端到端直接生成和基于模板槽位填充。端到端生成是当前大模型主流的做法把 “System Prompt Schema 用户问题” 一股脑丢给模型让它直接输出 SQL。这种方式优势很明显不需要人工设计模板模型理解复杂语义的能力强也容易处理带JOIN、GROUP BY、ORDER BY的查询。我在实验中大多数情况都走的这条路。模板填充更像传统 NLU 的做法先做意图识别和槽位提取比如识别出“时间范围”“地区”“指标”“聚合方式”然后往预写好的 SQL 模板里填。好处是结果绝对可控永远不会生成语法错误坏处是表达方式稍微灵活一点就识别不出来比如“上个月华北区卖了多少货”和“求最近一个自然月华北的销售总额”就会被拆成完全不同的意图。我最终的方案是折中对常见查询类型准备几个模板兜底优先让模型自由生成同时用规则引擎对生成的 SQL 做初步合法性检查。如果检查不通过再降级到模板匹配。这样既保住了灵活性又避免模型“自由发挥”过头。2.3 查询安全与结果校验避免 SQL 注入与误操作把自然语言转成 SQL 这件事天然有个安全红线你怎么保证模型不会生成DROP TABLE或者因为某个歧义把全表DELETE了我在这块做了三层防护。第一层是 Prompt 级别的硬性约束。在 System Prompt 里明确写“只允许生成 SELECT 查询禁止生成 INSERT、UPDATE、DELETE、DROP、ALTER 等非查询语句”。大模型在遵守这种明确指令上表现得还不错但并非 100% 遵守所以不能只有这一层。第二层是 SQL 静态检查。用sqlparse库解析生成的 SQL提取第一个 token确保是SELECT同时检查是否包含;以及多个语句。另外我还会用正则把insert|update|delete|drop|alter|create|attach|detach|pragma这些危险关键词直接标记出来只要命中就拒绝执行。第三层是执行隔离。SQLite 允许用connect(:memory:)创建内存库但生产环境显然不能这么干。我做的折中方案是在每次执行前先复制一份只读的数据库快照或者用sqlite3.connect(file:path?modero, uriTrue)以只读模式打开连接。这样就算 SQL 有问题也改不了数据最多是查询报错。3. 动手实现一个最小可用的 Text2SQL 工具3.1 环境准备与选型说明我用的是 Python 3.10 sqlite3 OpenAI API。选择 OpenAI API 一方面是因为它的gpt-4o-mini这类模型在 SQL 生成上的表现比较稳另一方面是生态文档相对丰富容易查资料。如果你有隐私考虑或者没法访问外部 API完全可以替换成本地开源的Qwen2.5-Coder或DeepSeek-Coder后面我会聊具体替换思路。依赖库只需要两个pip install openai sqlparseopenai用来调用大模型接口sqlparse用来做 SQL 解析和基础的合法性校验。其他像pandas这种只在展示结果时用非必须。3.2 核心代码实现连接数据库、构造 Prompt、生成 SQL我习惯把核心流程拆成三个函数get_schema_info前面已实现、generate_sql、execute_sql。其中generate_sql是重点import sqlparse from openai import OpenAI client OpenAI() # 假设你已经在环境变量里配置了 OPENAI_API_KEY def generate_sql(schema, question): system_prompt 你是一个 SQLite 专家。用户会提供数据库的表结构和字段信息以及一个自然语言问题。 请根据问题和表结构生成对应的 SQLite SQL 查询语句。 要求 1. 只能生成 SELECT 语句禁止生成任何其他类型的 SQL。 2. 不要输出额外解释只输出 SQL 本身。 3. 如果问题涉及的时间表达如上个月需要基于 SQLite 的 date 函数计算。 4. 如果表结构中没有相关字段请输出 NULL。 user_prompt f数据库表结构如下\n{schema}\n\n用户问题{question} response client.chat.completions.create( modelgpt-4o-mini, temperature0, messages[ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], ) sql response.choices[0].message.content.strip() # 清理 markdown 代码块包装 sql sql.replace(sql, ).replace(, ).strip() return sql这段代码有几个细节值得说。temperature0很关键。SQL 生成是确定性任务不需要模型发挥创造性温度设为 0 能显著降低随机生成错误 SQL 的概率。另外 Prompt 里明确要求“只输出 SQL 本身”但模型偶尔还是会用 markdown 代码块包一层所以我在返回前做了字符串清理去掉sql 和。3.3 执行与校验的完整闭环光生成 SQL 还不够必须经过校验才能执行。我写了这样一个函数def is_safe_select(sql): # 去掉首尾空白后必须是以 SELECT 开头 stripped sql.strip().upper() if not stripped.startswith(SELECT): return False # 不允许出现多语句 if stripped.count(;) 1: return False # 不允许出现危险关键词 dangerous [INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, ATTACH, DETACH, PRAGMA] for word in dangerous: if word in stripped: return False return True def execute_sql(db_path, sql): if not is_safe_select(sql): raise ValueError(SQL 安全检查未通过) # 只读模式打开 SQLite避免任何写操作 conn sqlite3.connect(ffile:{db_path}?modero, uriTrue) cursor conn.cursor() cursor.execute(sql) cols [desc[0] for desc in cursor.description] rows cursor.fetchall() conn.close() return cols, rows这里的modero是 SQLite 自带的能力通过 URI 传参实现只读打开。哪怕你的 SQL 被注入了一段神代码在只读模式下也没法改数据安全感拉满。到这一步一个最小可用的 Text2SQL 工具已经能跑通了。你只需要把用户问题传给generate_sql再把结果交给execute_sql就能拿到查询结果。3.4 测试集验证别凭感觉评估效果很多教程到上面就结束了但真正到了实际使用你会发现准确率没有想象中那么高。所以我的习惯是建立一个小型测试集用固定的问题列表反复评估生成效果。我把这些问题分成了几类类型示例单表简单查询“查询所有订单数量”条件过滤“查询 2024 年 1 月华东区的订单”聚合统计“统计每个产品的总销售额按销售额降序”多表关联“查询订单表中包含的产品的分类名称”时间计算“查询最近 7 天的订单”每个问题我都会提前写好标准 SQL 作为答案然后让模型生成再人工比对结果。通过率低于 80% 就说明 Prompt 或者 Schema 上下文还有问题需要调优。这套测试集后来成了我迭代的第一抓手每次改完 Prompt 都会回归一遍。4. 实测中的意外与调优几个典型翻车案例4.1 列名歧义导致的错误 SQL我最早用的表结构里有amount字段同时还有另一张表也有amount字段。用户问“每个客户的平均消费金额”模型给出的 SQL 写成了SELECT customer_name, AVG(amount) FROM orders GROUP BY customer_name;单独看没毛病但如果你的业务里订单总金额是total_amount明细行金额是line_amount那amount这个字段名本身就不存在。模型在 Schema 里看到amount时有多个候选它不一定能猜到你说的其实是total_amount。解决方法是两件事一是把字段注释加进 Schema比如total_amount (订单总金额单位元)这样模型就有了语义锚点二是在 Prompt 里加一句“如果字段含义不明确请优先选择名称与问题语义最匹配的字段不要自行编造字段名”。4.2 值过滤条件中的格式问题SQLite 的日期存储格式是个大坑。我的order_date字段存的是2024-01-15这种标准格式但用户说“查一下一月份的订单”模型一开始生成的是SELECT * FROM orders WHERE strftime(%m, order_date) 1;这逻辑上没错但跑出来行数不对因为忽略了年份。用户说“一月份”往往隐含的是“今年的 1 月”或者“最近一个完整自然月”。模型不能理解这种模糊含义就需要我们在 Prompt 里定义清楚业务规则。我后来在 Prompt 里增加了这样的约束如果用户提到某月且未指定年份默认使用当前年份使用 strftime(%Y, now) 获取。 如果用户提到上个月请使用 date(now, start of month, -1 month) 计算上个月的起始日期。加上规则之后类似的日期问题解决率提升了不少。但也要注意规则不能写得太死否则用户换一种说法又失灵。更好的做法是在代码里先解析出时间区间把“上个月”转成具体的起止日期再填进 SQL但这是另一个话题了。4.3 多表关联时的表名推断问题有一次用户问“哪些产品没有卖出去过”模型生成了一堆LEFT JOIN但连错了字段把产品表和订单表的关系写反了。原因还是 Schema 里没有体现表之间的外键关系。SQLite 本身不强制外键所以模型看不到products.id orders.product_id这种线索。我在 Schema 结构里增加了“表关系”信息通过在连接数据库后手动读取外键索引如果有或者直接硬编码关系描述比如表 products 和表 orders 通过 products.id orders.product_id 关联把这些关系描述拼在 Schema 后面模型生成JOIN的准确率明显提高。如果你的数据库没有明确的表关系注释这一步一定不要偷懒。4.4 通过上下文压缩与示例增强提升准确率还有一个提升准确率性价比很高的手段Prompt 里给示例。Few-shot 示例对模型理解输出格式和业务语义帮助极大。我整理了 5 个典型问题及答案的配对放到 System Prompt 的最后面比如示例问题每个地区的订单总量 示例 SQLSELECT region, COUNT(*) FROM orders GROUP BY region示例不需要太多覆盖最简单的单表聚合、多表关联、时间过滤即可。模型会照着示例的风格和字段习惯去生成比纯粹描述规则要直观得多。另外当 Schema 字段非常多时Prompt 长度会变长token 成本上升模型也容易“看花眼”。我的办法是做一次上下文裁剪先让模型根据用户问题选出可能相关的表只保留这些表的 Schema。这一步可以用一个小模型来做也可以用规则匹配字段名关键词来实现效果都很明显。5. 生产化改造与进阶方向5.1 封装成 API 服务从脚本到多人可用如果你做的东西只想自己用脚本就够了。但要给同事用最好封装成一个 HTTP API。我用 FastAPI 包了一层接口很简单from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class QueryRequest(BaseModel): question: str app.post(/query) def query(req: QueryRequest): schema get_schema_info(business.db) sql generate_sql(schema, req.question) cols, rows execute_sql(business.db, sql) return {sql: sql, columns: cols, rows: rows}前端用任何支持 HTTP 请求的方式就能接入甚至可以接到企业微信机器人上让同事在聊天框里直接问。Package 成 API 之后还要考虑一个问题并发。SQLite 对并发写有限制但只读查询场景下问题不大modero多个连接同时读是安全的。5.2 支持写操作的风险兜底不少业务场景下用户不只是查数据还想“把张三的订单状态改成已发货”。这种写操作我用意很深地劝退了一大半需求因为风险实在太高。如果一定要做我建议至少加四道关卡权限校验只有白名单用户才能调用写操作接口。显式确认生成的UPDATE或DELETE语句必须包含WHERE条件没有WHERE直接拒绝。影响行数预览先执行SELECT COUNT(*)预估影响行数让用户确认。事务回滚在事务里执行写操作用户说“确认”才COMMIT。即便是这样我依然建议把写操作设计成审批流而不是让用户直接执行。自然语言到 SQL 的准确率哪怕到了 95%5% 的错误在写操作场景里可能就是不可逆的灾难。5.3 从 SQLite 扩展到 MySQL/PostgreSQL 的迁移思路SQLite 跑通之后自然会想扩展到更大的数据库。这个迁移没有想象中复杂核心要改的就三点Schema 获取方式从PRAGMA table_info换成SHOW COLUMNS FROM或information_schema。分页/方言 SQLSQLite 的LIMIT和 MySQL 的LIMIT在语法上差别不大但日期函数、字符串拼接等函数差异很大。比如 SQLite 用date()、strftime()MySQL 用DATE_FORMAT()PostgreSQL 用TO_CHAR()。所以 Prompt 里要明确指明数据库方言。连接方式从sqlite3换成pymysql或psycopg2同时保留只读账户做查询隔离。我用同样的代码结构适配过 MySQL改动量大概在一百行以内主要是 Schema 获取和连接参数。整个 Text2SQL 主流程完全不用动。5.4 接入本地模型的开源替代方案聊到隐私问题很多人不愿意把业务数据发给外部 API。这时可以用本地模型替代。我尝试过Qwen2.5-Coder-7B-Instruct在 SQLite 场景下效果和 GPT 系列差距不大尤其是常见查询完全够用。接入也很简单把 OpenAI 客户端替换成 OpenAI 兼容的本地推理服务比如vLLM或Ollama接口格式几乎不变client OpenAI( base_urlhttp://localhost:8000/v1, # 本地推理服务地址 api_keyEMPTY )本地模型最大的好处是数据不出内网适合有合规要求的场景而且单条查询的成本趋近于零。代价是需要一台有一定算力的机器以及模型容量有限带来语义理解上限。如果你手里的 SQL 复杂度不高本地模型是非常值得长期投入的方向。最后说几句开发体会我把这套 Text2SQL 工具在内部跑了一段时间最大感悟是它真正解决的不是“让数据库听懂人话”而是把重复性的取数工作从开发身上解放出来。做这类项目的关键不是模型选得多前沿而是想清楚业务边界做好兜底和验证。很多刚开始接触的朋友会把重心放在“怎么让模型更聪明”上但我踩过几次坑之后意识到先回答“模型出错了我能不能挡住”更重要。只读模式、安全检查、测试集回归这三样东西是一个能用的 Text2SQL 工具的最低门槛。如果你正准备动手我建议先不要过度设计找一张简单的单表把 Schema 抽取、SQL 生成、只读执行这条链路跑通再慢慢加上多表、写操作和 API 封装。这个迭代路径走起来很顺。后面我也打算把测试集扩展成语义级评估让每一个自然语言问题都能自动判断 SQL 结果的正确性进一步减少人工核对的工作量。
返回列表