上个月帮朋友的公司搭了一个内部数据问答Demo。财务总监随口问了一句:“这个季度各区域的回款率怎么样?”系统在几秒内返回了一条SQL和准确的汇总数字,他愣了一下,转头问我:“这个能替代我们组的报表取数吗?”我说不能完全替代,但至少能把他手下三个数据分析师从“天天跑临时查询”的琐事里救出来大半。
这里用到的工具就是Vanna AI,一个把自然语言翻译成数据库查询的开源工具。它最核心的思路可以概括成RAG2SQL:先检索出和你当前问题最相关的库表结构、业务口径、历史SQL样例,再让大模型基于这些信息去生成SQL。换句话说,不是让模型凭感觉写一段SQL,而是让它“先查资料再动手”。
这篇文章我想聊三件事:第一,RAG2SQL到底解决了传统Text2SQL的哪些痛点;第二,怎么用Vanna在本地快速跑通一个真实可用的自然语言查询系统;第三,生产环境下有哪些容易被忽略的坑,我会把实际踩过的记录一并放出来。
1. 从Text2SQL翻车现场说起:RAG2SQL改了什么
如果你以前玩过“直接让ChatGPT写SQL”这件事,大概率遇到过这样的场景:你给它一张订单表,问“上季度每个区域的销售额是多少”,它写出来的SQL里出现了一个根本不存在的列名;或者你问“回款率”,它把应收账款直接除以订单金额,算出来的结果和财务口径完全对不上。这不是模型笨,而是它压根不知道你的数据库长什么样。
1.1 裸Text2SQL为什么容易翻车
大模型本质上是一个“会话高手”,它非常擅长根据你给的提示词组织出看起来合理的回答,但合理不等于正确。对于SQL生成来说,它必须知道三件事:你的库表结构是什么、每个字段的业务含义是什么、业务上“销售额”“回款率”这类名词的准确计算口径是什么。
裸Text2SQL做法的致命问题在于,模型在生成SQL时,所有关于数据库的信息都来自于你临时塞进Prompt里的只言片语。信息不全的时候,它就靠“语言习惯”去补全,结果就是编造列名、乱建表关联、把时间过滤条件写错。这些问题不是因为模型能力差,而是因为上下文先天不足。
1.2 RAG2SQL的本质:让模型先查资料
RAG2SQL的思路和裸Text2SQL完全不一样。它把“数据库知识”提前整理好,向量化之后存进一个向量数据库。你提一个问题,系统先把这个问题和历史所有训练材料做相似度检索,找到最相关的几张表结构、几段业务口径说明、几组相似的历史SQL,再把它们作为上下文包装进Prompt,最后才让模型生成SQL。
这个过程有点像新人入职:你不会让他直接开始写报表SQL,而是先给他看数据字典、业务文档、以及过去半年的取数脚本。他遇到问题先翻资料,翻完了再动手,犯错的概率自然就低了很多。
Vanna的整体架构很清晰,分为两个阶段:
- 训练阶段:把DDL建表语句、文档说明、示例SQL、问题与答案对,经过Embedding模型转成向量后存入向量库。
- 查询阶段:用户输入自然语言问题,先做向量检索拿到相关上下文,然后交给LLM生成SQL,最后执行并返回结果。
这个设计和传统概念的“Text2SQL”最核心的区别,在于生成环节的前面多了一个“知识检索”环节,因此业界把这种思路叫RAG2SQL也比较贴切。
| 对比维度 | 裸Text2SQL | RAG2SQL |
|---|---|---|
| 模型对库结构的感知 | 靠当前Prompt临时提示 | 训练入库,检索时自动命中 |
| 业务口径理解 | 容易凭常识编造 | 显式文档化,口径随上下文注入 |
| 复杂JOIN处理 | 容易关联错字段 | 相似历史SQL作为示例纠偏 |
| 冷启动成本 | 低,但准确率不可控 | 需要先整理少量训练材料 |
如果你之前用过“在Prompt里贴一堆建表语句再让模型写SQL”的方案,你其实已经在手动做RAG2SQL里“检索上下文”这一步了。Vanna做的事情,就是把这个过程自动化、工程化,并且把常用的业务知识沉淀下来反复使用。
2. 搭一套能用的Vanna环境:版本、依赖与模型组合的取舍
Vanna的安装比我想象中简单,但有几个点如果不提前搞清楚,很容易在环境层面卡很久。它本身是一个可插拔的框架,核心由三块拼装起来:大模型负责生成SQL,向量库负责存储和检索训练知识,数据库连接负责执行SQL。
2.1 典型组合与环境准备
我的环境是Python 3.10,用的虚拟环境。安装命令很简单:
pip install vanna如果你选择ChromaDB作为向量库,安装过程中通常会把相关依赖一起处理掉;如果后面运行时报缺包,单独补装即可:
pip install chromadb数据库驱动看你的实际目标库,比如PostgreSQL需要psycopg2,MySQL需要pymysql,SQL Server需要pyodbc。我这里拿PostgreSQL做示例:
pip install psycopg2-binary2.2 选择LLM和向量库组合
Vanna最舒服的地方是模型和向量库都可以自己换。官方提供了一些预置组合,常见的配置方式如下:
from vanna.chromadb import ChromaDB_VectorStore from vanna.openai import OpenAI_Chat class MyVanna(ChromaDB_VectorStore, OpenAI_Chat): def __init__(self, config=None): ChromaDB_VectorStore.__init__(self, config=config) OpenAI_Chat.__init__(self, config=config) vn = MyVanna(config={ "api_key": "sk-你的密钥", "model": "gpt-4o-mini", "temperature": 0, })看到class MyVanna同时继承两个父类,你可能会愣一下。这是Vanna的设计:它把生成和检索解耦成两个独立组件,用多重继承拼装成一个完整实例。将来如果想换向量库,把ChromaDB_VectorStore换成别的;想换LLM,把OpenAI_Chat换成对应的客户端实现,互不影响。
向量化embedding这一步,默认方案会在本地运行一个轻量模型来把文本转成向量,不需要额外调用API,这也是RAG方案里一个容易被忽略的优点:每次训练和检索的向量化不产生额外费用,只有真正让LLM生成SQL才消耗Token。
如果你的生产环境完全内网隔离,不想调外部API,也可以把OpenAI_Chat指向自建的OpenAI兼容接口:
vn = MyVanna(config={ "api_key": "本地密钥", "model": "qwen2.5-coder", "api_base_url": "http://内网地址/v1", })只要接口兼容OpenAI协议,Vanna基本都能直接对接。这个灵活性非常实用,尤其对数据敏感的企业来说,可以做到全链路不出内网。
2.3 模型选择上的一些个人取舍
| 目标场景 | 推荐方案 | 备注 |
|---|---|---|
| 快速验证想法 | gpt-4o-mini / 国内厂商小参数模型 | 成本低,生成SQL够用 |
| 复杂查询为主 | 更强的旗舰模型 | 多表关联、嵌套子查询更稳 |
| 数据不能出内网 | 自部署OpenAI兼容服务 | 通过api_base_url接入 |
| 低成本高频查询 | 使用缓存和历史SQL复用 | 降低LLM调用次数 |
经验上,temperature一定要设成0。SQL生成是确定性任务,不是创意写作,过高的随机性会让同样的语义问题生成出不同的SQL,线上排查起来非常头疼。
3. 第一次跑通自然语言查询:从训练数据源到SQL落地的完整链路
环境搭好后,真正要花心思的是“训练”这一步。Vanna里所谓的训练,不是传统意义上的微调模型,而是把关于数据库的各类知识向量化后存入检索库。这里的知识指四类内容:表结构信息、文档描述、示例SQL、问题与SQL配对。
3.1 连接数据库并把表结构信息入库
先用Vanna连接到你的PostgreSQL:
vn.connect_to_postgres( host="127.0.0.1", dbname="sales_dw", user="readonly_user", password="xxxx", port=5432 )我建议用一个只读账号来连接,原因后面会专门讲。连接之后,第一步是训练表结构。Vanna支持直接传DataFrame来自动抽取字段统计信息:
import pandas as pd # 从一个表里抽样一部分数据作为字段样本 df = pd.read_sql("SELECT * FROM orders LIMIT 100", con=vn._connection) vn.train(df=df)也可以直接训练DDL信息,把建表语句作为知识存进去:
vn.train(ddl=""" CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT NOT NULL REFERENCES customers(customer_id), order_date DATE NOT NULL, region VARCHAR(20) NOT NULL, amount NUMERIC(12,2) NOT NULL ); """ )这里有个细节:DDL训练项在生成SQL时的作用是让模型“知道这张表存在、有哪些字段、类型是什么”。但光有表结构还不够,它不知道“回款率”怎么算,这就要靠文档训练。
3.2 把业务口径和常用SQL写进知识库
我在训练阶段最重视的是业务口径文档。比如销售库里的订单金额其实是含税价,而财务平时说“销售额”时是要剔除税金的。如果不把这种口径写清楚,模型生成出来的SQL就会和你预期差一大截:
vn.train(documentation=""" 销售额 = SUM(orders.detail_amount_excluding_tax) 回款率 = SUM(payments.received_amount) / SUM(invoices.invoice_amount) 订单默认统计最近90天数据,除非用户明确指出其他时间范围。 """ )文档的作用是给模型提供业务共识。遇到“这个季度各区域回款率”这种查询时,向量检索会同时把related的文档和表结构都捞出来,模型看到上下文里明确了“回款率=已回款/应收”,自然就不会胡编公式。
历史SQL的训练也很实用。把过去常用的取数脚本挑几份,按问题加SQL的方式配对存进去:
vn.train( question="华东区上季度销售额最高的10个客户是谁?", sql=""" SELECT c.customer_name, SUM(oi.quantity * p.price) AS total_sales FROM orders o JOIN order_items oi ON o.order_id = oi.order_id JOIN products p ON oi.product_id = p.product_id JOIN customers c ON o.customer_id = c.customer_id WHERE o.region = '华东' AND o.order_date >= DATE_TRUNC('quarter', CURRENT_DATE) - INTERVAL '3 months' GROUP BY c.customer_name ORDER BY total_sales DESC LIMIT 10; """ )这条训练记录的意义在于:当你再问“华北区上季度销售额最高的客户”这类相似问题时,向量检索会把这个SQL作为few-shot示例交给模型,模型会照着相似的写法去生成新的SQL。这种“以例促写”的效果,比单纯给表结构要强得多。
3.3 从问题到SQL再到结果的完整链路
训练完之后,真正的查询就很简单了。先用generate_sql看模型生成的SQL:
sql = vn.generate_sql("华东区上季度销售额最高的10个客户是谁?") print(sql)再把SQL交给Vanna执行:
df = vn.run_sql(sql) print(df.head())如果你想一步到位,直接调ask方法,它会自动完成生成SQL到执行的全过程:
df = vn.ask(question="华东区上季度销售额最高的10个客户是谁?", visualize=False)你可能会好奇,为什么这个看似刁钻的问题能生成出如此靠谱的SQL。拆开来看,关键在于两点:一是训练时把“销售额=订单明细数量乘以单价”这个口径写进了文档,模型不会自己凭感觉定义;二是DDL训练项里包含了order_items和products两张表的字段信息,模型能正确推断出关联关系。
我见过不少刚接触Vanna的朋友,跳过了训练步骤直接让它生成SQL,然后抱怨“效果不如预期”。其实RAG2SQL这个方案的准头,七分靠训练语料的质量,三分靠模型的能力。训练资料准备得越贴合业务,生成的SQL就越接近一个懂业务的开发写出来的水平。
4. 让结果更精准的四个训练策略与踩坑记录
跑通Demo不算难,难的是让它在真实业务里稳定输出。我从第一次试验到现在,踩过不少坑,总结下来四个策略对准确率的影响最大。
4.1 DDL瘦身与字段注释:别把整库信息一股脑喂进去
我最早犯的错误是图省事,把整个生产库的表结构都抽出来训练。想着知识越多越好,结果生成SQL的质量不但没有提升,反而下降了。原因是向量检索的维度被稀释了:问销售问题时,检索结果里混进了库存、物流、人事等无关表,模型容易在关联的时候跑偏。
正确的做法是只训练核心业务表,并且保证DDL里有字段注释。下面是我常用的处理思路:
# 从information_schema里筛出核心主题相关的表 table_filter = ["orders", "order_items", "products", "customers", "payments", "invoices"] for tbl in table_filter: ddl = fetch_schema_with_comments(tbl) # 包含COMMENT的建表语句 vn.train(ddl=ddl)建表语句里的COMMENT对中文业务尤其重要。字段名经常是拼音缩写或英文,没有注释的情况下,模型很难理解fld03到底是什么意思;一旦注释里写了“创建时间”,模型就能准确把时间过滤条件写到这个字段上。
4.2 业务口径文档化:把口头共识变成模型的上下文
我测试过同一个业务问题,在两种情况下生成SQL的差异:一种情况是训练库里没有口径文档,另一种是明确写入了“销售额剔除税金”的说明。结果很明显,没有口径文档时,模型有时候会用含税金额直接求和;有文档时,生成的SQL稳定使用了detail_amount_excluding_tax字段。
这也让我明白了一件事:业务团队的很多“常识”,模型是完全不知道的。你在公司里待久了觉得“回款率当然是用已回款除以应收”,但这些信息在大模型的知识体系里根本不存在,它的训练语料来自于公开互联网。所以凡是业务上独有的、有特殊口径的指标,都应该写进documentation训练项里。
文档描述不用追求文学性,清楚就行,类似于给一个新同事发的口头说明:
vn.train(documentation=""" - 销售订单状态为'shipped'或'completed'的才计入有效销售额 - 退款订单单独用负数金额冲抵,计算净销售额时直接SUM(amount)即可 - dt字段是分区字段,查询时必须带上,否则会扫描全表 """ )这些内容看起来零碎,但恰恰是模型最容易犯错的地方。
4.3 用历史SQL做样本,但要做泛化和清洗
从慢查询日志、BI平台的取数记录里,能捞到大量优质历史SQL。把这些SQL转成问题加SQL的训练样本,是拖高准确率最直接的手段。
但这里有个细节容易翻车:训练样本里的问题表述如果太固定,换个说法就检索不到了。比如你存了一条“最近7天订单数量”,用户问“过去一周的单量”,相似度匹配时语义距离较远,可能就匹配不上。我一般会为同一条SQL写两三种问法,让检索命中率更高:
vn.train( question="最近7天一共产生了多少订单?", sql="SELECT COUNT(*) FROM orders WHERE dt >= CURRENT_DATE - 7" ) vn.train( question="过去一周的单量是多少?", sql="SELECT COUNT(*) FROM orders WHERE dt >= CURRENT_DATE - 7" )另外,从生产环境捞出来的SQL经常带着具体的参数值,建议把数字占位符改写成相对描述或参数化形式,让样本更通用。比如把“where order_date >= '2025-01-01'”改成“where order_date >= CURRENT_DATE - INTERVAL '1 year'”,这样模型学到的是逻辑,而不是死的日期。
4.4 增量更新与坏样本治理
业务是变化的,表结构会改,指标口径也会调整。Vanna训练过的知识不会自动更新,所以你需要建立一套更新机制。表结构变了,最好把对应表的旧DDL训练项清理掉再重新训练;口径变了,修改文档训练项后重训。
Vanna里可以通过remove_training_data来删除指定训练数据,或者用remove_collection清空整个知识库后重建。我的做法是写一个refresh脚本,对核心表定期重训,避免知识库和实际库结构脱节。
还有一个很重要的治理环节:坏样本反馈。每次查询失败,不管是SQL语法错误还是结果明显不符合业务直觉,都要把这次失败记录归档。我现在的做法是把失败的question和SQL记录下来,人工修订后作为新的训练样本补入知识库。这个过程一开始会比较枯燥,但积累一定量之后,系统对相似问题的回答会越来越稳。
5. 把Vanna放进生产环境:服务化、权限边界与效果监控
Demo跑通了、准确率也能接受了,接着要考虑的是怎么把它像正式系统一样落地。这里的安全和服务化问题,我认为比模型选型更关键。
5.1 封装成API服务的极简实践
Vanna的常见用法是Python客户端,但生产环境通常要暴露成HTTP接口给业务方或前端使用。我一般用FastAPI包一层:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class QueryRequest(BaseModel): question: str @app.post("/ask") def ask(req: QueryRequest): result_df = vn.ask(question=req.question, visualize=False) return {"answer": result_df.to_dict(orient="records")}注意一点:Vanna实例初始化的时候会建向量库连接和数据库连接,这些开销不小,不要在每次请求进来时都重新初始化。建议在服务启动时创建全局实例,请求里只调用查询逻辑。如果业务量大了,可以用线程池限制并发,或者按多租户方式为每个租户维护一个独立的Vanna实例,避免不同业务的数据互相污染检索结果。
5.2 SQL安全:只读账号是第一道防线
这是我最想强调的一点。Vanna能干活的本质是它能生成SQL并且帮你执行它,所以如果连接的账号权限过大,一旦生成的SQL里出现删除或修改操作,后果不堪设想。模型再聪明也保不齐会出错,安全必须靠机制兜底。
第一道防线是数据库账号。给Vanna单独创建一个只读账号,只授予SELECT权限:
CREATE USER vanna_ro WITH PASSWORD '强密码'; GRANT CONNECT ON DATABASE sales_dw TO vanna_ro; GRANT USAGE ON SCHEMA public TO vanna_ro; GRANT SELECT ON ALL TABLES IN SCHEMA public TO vanna_ro;第二道防线是应用层的SQL校验。在Vanna执行SQL之前,先做一道关键词拦截:
forbidden = ["delete", "update", "drop", "alter", "truncate", "create", "insert", "grant"] if any(word in sql.lower() for word in forbidden): raise ValueError("检测到疑似非查询语句,已拦截")这道正则拦截很简单但很实用,即使只读账号配置不到位,应用层也能拦住绝大多数危险操作。更稳妥的方案是让数据库管理员在PostgreSQL里设置statement-level触发器或使用read-only事务模式,属于可选的加固项。
第三道防线是行级权限。如果业务上要求不同部门只能看自己区域的数据,建议不要指望Vanna自动帮你加where条件,而是从数据库层面通过视图、行级安全策略来控制。Vanna生成SQL的能力再强,它也没有办法替你决定谁能看哪些数据。
5.3 效果监控与训练闭环
我见过很多团队把Vanna部署上去就不管了,结果用了一周后准确率下降,还以为是模型变笨了。其实不是,多半是知识库和最新的业务变化脱节了。
建议至少记录这些信息:
| 监控项 | 具体内容 | 关注原因 |
|---|---|---|
| 问题输入 | 用户原始提问 | 沉淀高频问题,补训练样本 |
| 生成SQL | Vanna产出的SQL | 分析错误模式,定位训练缺口 |
| 执行结果 | 是否报错、行数、耗时 | 发现慢查询和语法错误 |
| 人工反馈 | 用户是否点了“结果有帮助” | 直接反映生产准确率 |
我现在的做法是,每天把失败查询导出来看一眼,找共性原因。大部分问题集中在两类:一类是新表没有训练DDL,模型不知道字段;另一类是业务新词没有在文档里定义。把这两个缺口补上,准确率很快就能回升。
5.4 认清边界:它不是万能BI
最后想泼一盆冷水。Vanna不适合做特别复杂的探索式分析,比如“对比去年和今年各渠道的客单价变化并解释原因”,它擅长的是把明确问题翻译成SQL,而不是充当数据分析师。多轮对话上下文理解也比较弱,它处理的是单轮查询,记不住你上一句问过什么。
如果你想在团队里推广,我建议先挑几个高频问题做成快捷入口,把“用户自由输入”限制在一个可控范围内。这样既能跑通流程,也不会一上来就被各种边缘问题打趴。等训练库积累到一定程度,再逐步放开自由提问的比例。
写在最后
如果只能分享一条实操体会,我会说:在Vanna这类RAG2SQL系统里,投入产出比最高的优化动作是整理数据字典和业务口径文档,而不是换一个更大的模型。我踩过最深的坑是在初期把整库DDL无脑灌进去训练,效果反而不如后期按主题拆分、配好文档的训练方案。另一个底线建议是生产环境务必使用只读账号,这和工具本身的SQL生成质量无关,是任何查询服务的通用安全要求。如果你最近也在尝试自然语言查数据库,建议从小范围业务表和几个高频问题开始,先把闭环跑顺,再慢慢扩展知识库。