资讯详情

资讯详情

数据目录到Text-to-SQL:六层架构打通自然语言查询

1. 先说个现象Text-to-SQL 的效果瓶颈往往不在模型在数据目录这几年在企业数据资产建设里做 Text-to-SQL越做越觉得真正决定天花板的不只是大模型而是上游的数据目录。很多团队上来就调 Prompt、换模型、上微调结果效果还是不稳定。我见过不少项目用户问一句“上个月华东区退货率是多少”模型生成的 SQL 里表名和字段名全是编的或者 JOIN 条件写错最后结果差了十万八千里。问题不是模型不够聪明而是模型根本看不到可信的元数据。很多人第一次听到“数据目录”这四个字脑子里冒出来的可能是微信数据目录下有以前版本聊天记录、需要迁移存储路径这类场景。那种“数据目录”本质上是文件系统里的一个文件夹跟我们聊的企业级数据目录完全不是一回事。我在这里要讲的“数据目录”是面向企业数据平台的一层元数据中心它管理的是表结构、字段含义、业务口径、表间关系、数据血缘、指标定义这些东西。它不存业务数据本身但它是业务数据能被机器理解的关键前提。为什么 Text-to-SQL 特别吃数据目录因为自然语言转 SQL 这个任务本质上是一个“从开放语义空间到封闭 schema 空间”的映射。用户的话是千变万化的但最终 SQL 只能落在你库里真实存在的表、字段、枚举值和关联关系上。如果模型不知道有哪些表、哪些字段、哪些字段代表什么业务含义它就只能靠猜。猜得越多错得越离谱。数据目录就是用来把“猜测空间”缩小的它负责告诉模型你只能在这些表、这些字段里做文章别出去乱逛。所以这篇文章我想认真梳理一下从数据目录到 Text-to-SQL中间到底要经过哪几层每一层做什么层与层之间怎么交接。不是泛泛讲概念而是把每一层的输入输出、接口契约、交接原则说清楚。对正在做企业知识问答、BI 增强、内部 ChatBI 这类系统的同学应该会有直接帮助。1.1 数据目录不是“给人看的文档”是“给机器吃的上下文”很多企业的数据目录项目做出来就是一个网页上面列了表名、字段名、负责人、更新时间业务人员打开看一眼觉得信息太散关掉再也不用了。这种目录本质上是给人看的文档对 Text-to-SQL 没什么用。真正要支撑 Text-to-SQL 的数据目录必须保证两点第一机器可读第二语义完整。机器可读意思是目录里所有信息都能通过 API 或文件批量拉取而不是只能靠人用浏览器翻。语义完整意思是每张表、每个字段都要有足够丰富的描述信息。比如字段amt如果只写“金额”那跟没写一样大模型不知道它是销售额、毛利还是应付账款。如果写成“订单实付金额单位为元不含运费和优惠券抵扣”模型的准确率会立刻上一个台阶。数据目录在这个场景里承担的角色是“给模型提供上下文素材库”不是给人看的数据字典。1.2 Text-to-SQL 的真正难点表越多模型越容易“迷路”单张表上的 Text-to-SQL 其实不难难点在于几千张表、几十万字段的时候模型根本不知道该看谁。我见过最典型的情况一张订单表叫ods_order_info还有一张财务表叫ods_t_finance_order两表字段高度重叠但业务口径不同。模型如果选错表生成的 SQL 语法完全没问题结果却完全不可用。这种错误单纯调 Prompt 解决不了本质上是“候选空间太大模型没有足够信息做正确取舍”。数据目录在这里的价值是提供一张“地图”。模型不需要扫全库几万张表而是通过目录的检索能力先从全集中召回几张最相关的表再基于字段级描述和关系信息做精细判断。这个过程有点像人查地图先在全国地图里定位到城市再放大到街道最后才找门牌号。没有目录就等于让模型蒙着眼睛在全国地图上找一家小吃店能找到才怪。2. 分层架构从用户一句话到可执行 SQL我习惯拆成六层我习惯把“数据目录到 Text-to-SQL”的完整链路拆成六层数据目录层、查询解析层、Schema 检索层、上下文组装层、SQL 生成层、执行与反馈层。每一层职责单一层与层之间靠明确的接口交接。这样做的好处是线上效果出问题的时候你能快速定位是哪一层出了问题而不是整个链路一起背锅。2.1 第一层数据目录层——把散落的元数据变成结构化资产这一层的核心任务是“接入、清洗、丰富”。接入指的是把 Hive、MySQL、ClickHouse、Kafka 等各类数据源的元数据定时抽取过来包括表名、字段名、字段类型、注释、分区信息。清洗则要处理各种脏数据比如字段注释乱码、表名重复、废弃表没有下线。丰富是整个层的重点我们需要给元数据补充三类信息业务语义、关系信息、使用热度。业务语义包括字段的中文名、枚举值含义、口径描述、所属业务域。关系信息包括外键、主外键推断、报表血缘、应用接口血缘。使用热度包括最近一周查询次数、最近被哪些报表引用等。这些信息不是天然存在的通常需要靠数据治理团队维护或者用 AI 自动补全后再人工确认。数据目录层输出的是一份被标准化过的 Catalog 数据集之后所有下游都只对着这一份数据说话不再直接连底层数据源。2.2 第二层查询解析层——把自然语言拆成“动作、对象、条件、口径”用户输入“近 30 天各品类销售额环比增长了多少”这一层需要把它解析成一个中间语义表示。我不太建议一步到 SQL因为一步到 SQL 会把很多中间决策混在一起出了问题很难拆解。我更习惯先让模型输出一个结构化的查询意图包含查询类型是趋势、比较、明细还是异常排查、目标业务对象销售、品类、统计维度日期、品类、过滤条件近 30 天、度量指标销售额、环比增长。这个中间表示的作用是给后续的 Schema 检索提供精确的“检索条件”。比如目标业务对象是“销售”系统就知道应该优先去目录里找销售域的表指标里有“销售额”系统就会去匹配目录里定义了“销售额”口径的字段或指标。如果这一步解析错了后面基本全错。所以我们要用强约束的 Prompt 加上少量示例让模型只输出固定 JSON 结构不要自由发挥。解析拿不准的时候宁可让系统反问用户也不要硬着头皮往下走。2.3 第三层Schema 检索层——从目录里召回“最可能的表和字段”这一层是目录和模型之间的桥梁也是决定最终 SQL 质量的关键。它的输入是查询解析层输出的 QueryIntent输出是若干候选表、候选字段和候选关联路径业内一般叫 Schema Linking 或者 Schema 召回。实现方式可以分为三类关键词检索、向量检索、知识图谱检索。实际生产里我通常是混合用先各召回一批再做融合排序。关键词检索很简单就是把 QueryIntent 里的业务词拆出来去目录的字段名、字段描述、中文名里面做 BM25 或 ES 检索。向量检索则是把 QueryIntent 和目录中每个字段的描述分别向量化用余弦相似度召回 TopK。向量检索对“语义相同但字面不同”的情况非常管用比如用户说“赚了多少钱”目录里字段叫profit_amt描述是“净利润”字面匹配不上但语义能匹配上。知识图谱检索则依赖于目录里的表关系网络比如命中了订单表通过血缘和主外键关系把关联的支付表、退款表也一起召回。2.4 第四层上下文组装层——把召回结果“翻译”成模型的 Prompt召回出来的内容通常还比较粗可能一个查询召回 20 张表、100 个字段满屏都是信息但模型实际用不了那么多。上下文组装层要做三件事裁剪、排序、模板化。裁剪是按照相关度分数和字段描述质量只保留最重要的表与字段。排序是把最相关的表放在 Prompt 前面让模型优先看到。模板化则是把目录里的结构化信息转成模型更容易理解的自然语言或半结构化文本。比如一张订单表的字段我不会按元数据原始顺序排列而是按“表名、表注释、关键字段列表、字段业务说明、该表与其他表的关系”来组织。这样组织出来的 Prompt和直接拿DESCRIBE table的输出拼接效果差别非常大。Context 组装层还要考虑 Token 预算。我通常会给整个 Prompt 设一个目标长度比如 3000 到 4000 token如果召回信息太多就要按重要度丢弃一些字段而不是一股脑全塞进去。2.5 第五层SQL 生成层——在限定范围内生成“可执行、可解释”的 SQL到了这一层模型要做的不是从零写 SQL而是根据前面给好的一组候选表、候选字段和关联关系在限定范围内完成 SQL 生成。这个限定范围非常重要。我在 Prompt 里会明确写清楚只允许使用下列表和字段不允许自己发明新表名JOIN 关系必须从给定的关系列表中选择聚合函数的使用需要结合指标口径说明。这样即使模型有幻觉倾向也被限制在一个相对安全的笼子里。SQL 生成层的输出不能只是一条 SQL 字符串。我习惯让模型同时输出字段解释、用到的表、行数限制说明、可能存在的风险点。这些附带信息后续要展示给用户也方便定位问题。比如用户问“利润”模型看到目录里有两个利润指标一个是“毛利润”一个是“净利润”它可能选了一个同时应说明“这里取的是毛利润口径如果需要净利润请告诉我”。这比扔一条 SQL 就跑强得多。2.6 第六层执行与反馈层——让错误留在系统里不让错误流向业务生成完 SQL 不能直接拿去跑数必须先过一道安全与合法性检查。首先语法检查用数据库自带的 EXPLAIN 手动验证有没有语法错误。然后是权限检查确认当前用户是否有权访问这张表、这个字段避免越权查询。接着是成本控制比如强制加上 LIMIT、限制扫描分区数、禁止不带 WHERE 条件的全表扫描。最后是结果预览先查 Top N 行让用户确认真是想要的东西后再放开全量结果。这一层还有一个特别容易忽略的工作把执行结果和错误信息回流到数据目录里。比如某张表字段描述不准导致模型反复用错系统可以在线把错误记录写回目录作为后续优化的候选样本。或者某次查询命中了目录中缺少血缘关系的一对表就可以把这个关系补录进去。反馈很重要没有反馈层的系统是死的只有不断回流目录才会越用越厚Text-to-SQL 也才会越用越准。3. 层与层怎么交接设计好“数据契约”整个链路就不容易乱很多人做分层架构每一层内部设计得漂漂亮亮但层与层之间的数据交接非常随意。上游传一个结构不稳定的 JSON 给下游下游再硬编码解析改一处崩一片。我后面是把每一层的输入输出都固化成“标准数据结构”就算内部逻辑重写接口不变上下游就不容易受影响。下面是我常用的一套数据契约你可以根据实际项目简化。3.1 每一层输出什么下一层才接得住我先定义五个核心结构CatalogEntry、QueryIntent、RelevantSchema、PromptContext、SqlCandidate。CatalogEntry 是数据目录层输出的原子单元每张表、每个字段都是它的实例。QueryIntent 是查询解析层输出的标准化意图。RelevantSchema 是 Schema 检索层输出的候选结果PromptContext 是上下文组装层输出的实际给模型的内容SqlCandidate 是 SQL 生成层输出的结果。下面是我在项目里会写到接口文档里的示意结构{ query_intent: { query_type: comparison, metrics: [sales_amount, mom_growth], dimensions: [category_name], filters: [order_date date_sub(current_date, 30)] }, relevant_schema: { tables: [ { table_name: dws_sales_daily, table_comment: 销售日汇总表按天聚合, score: 0.92, fields: [ {field_name: category_id, field_comment: 品类ID}, {field_name: sales_amount, field_comment: 销售额单位元不含退款} ] } ], join_paths: [ { left_table: dws_sales_daily, right_table: dim_category, on_condition: dws_sales_daily.category_id dim_category.category_id } ] }, sql_candidate: { sql: SELECT ..., explanation: 统计近30天各品类销售额及环比, risk: sales_amount 口径为不含退款, tables: [dws_sales_daily, dim_category] } }这里有一点很重要query_intent必须在进入 Schema 检索之前就稳定下来不要在relevant_schema产出之后又回头改意图。否则整个链路会振荡同一句话两次查出来结果不一样。我通常会在查询解析层加一个“意图置信度”字段低于 0.7 就触发澄清不进入下游防止把模糊意图一路带到 SQL 层。3.2 交接原则上下文要逐层“瘦身”不是层层加码每一层交接出去的数据应该比上一层更精炼而不是更膨胀。数据目录层可能有几十万字段到了 Schema 检索层只应该输出 Top 20 个字段到了上下文组装层再裁剪到 Top 10 个最后进模型的 Prompt 必须瘦得恰到好处。很多人做反了每一层都怕丢信息拼命往后面传结果 Prompt 撑爆模型注意力分散反而更差。我自己的经验是Schema 检索层输出的字段不超过 30 个上下文组装层最终保留不超过 8 个核心字段外加必要的维度表字段。如果用户的问题确实复杂需要涉及 15 个字段以上那就该考虑拆成多个查询步骤或者让模型先生成一个大 SQL再做一个后处理校验而不是一次性把所有字段堆给模型。瘦身不是丢信息而是把不相关信息过滤掉只留下能帮助模型决策的信息。3.3 交接中的关键细节血缘和关系怎么变成 JOIN 条件很多数据目录里其实有血缘关系但血缘是“任务级别的加工关系”比如 A 表经过 ETL 变成 B 表这不等于 SQL 里可以直接 JOIN B 表。真正做 Schema 检索层输出表时需要特别维护一份“可 JOIN 关系集合”这个集合可以来自外键声明、人工配置、或者历史 SQL 解析出来的关联关系。我见过最坑的情况是两张表字段名完全一样模型直接写了ON a.id b.id业务上根本不是同一种 ID结果数据全乱了。所以在目录层我建议额外构建一张table_relations表字段包括左表、右表、连接字段、连接类型1:n、n:1、业务说明、可信度。Schema 检索层在返回候选表时必须同时返回这些表之间的最优连接路径。如果目录里没有明确的 JOIN 关系宁可让模型生成一个简单的单表查询并提示用户“缺少关联表信息”也不要让模型自己瞎编 JOIN 条件。没有充分关系的 JOIN比不 JOIN 危害更大。3.4 交接之外每层要有 trace 和可观测性层与层交接的另一个关键是每一层都要给最终结果留下“痕迹”。我在系统里会给一次用户提问生成一个 trace_id从查询解析开始到最终 SQL 执行结束每层都会往这个 trace 里追加一条日志。这样出了问题我可以一路回放看是哪一层丢掉了关键信息。没有这种观测能力出了问题就只能靠人肉猜效率极低。具体来说每一层输出的数据快照都会进入一个诊断日志表。比如查询解析层记录了原始输入和解析后的 JSONSchema 检索层记录了召回的 Top 10 表及分数上下文组装层记录了最终 Prompt 的 token 数SQL 生成层记录了生成的 SQL 和模型返回的原始信息。这些数据不只用于排查问题也是一个极有价值的微调样本库。4. 实战拆解一个“近 30 天各品类销售额环比”的完整流转过程讲再多理论不如跟着一单真实查询走一遍。下面我用一个简化但完整的例子把六层流转过程展示出来。假设这是一个零售企业数据平台表不多但足够说明问题。4.1 假设的数据目录快照数据目录里目前有几百张表我把和本次查询相关的几张贴出来。第一张是dws_sales_daily销售日汇总表字段包括category_id、sales_date、sales_amount、order_cnt。其中sales_amount的注释是“当天支付订单金额单位元不含退款”category_id的注释是“品类 ID关联 dim_category.category_id”。第二张是dim_category品类维度表字段包括category_id、category_name、category_level。第三张是dws_refund_daily退款日汇总表字段包括category_id、refund_date、refund_amount。除此之外目录里还维护了一个指标定义表里面有一行“销售额 支付成功的订单金额 - 退款金额统计口径以 DWS 层 dws_sales_daily 和 dws_refund_daily 为准。” 这个定义很关键如果没有它模型很可能直接拿sales_amount当销售额忽略了退款部分。目录层的价值在这里就体现出来了。4.2 逐层流转输入、中间结果、最终 SQL用户输入原话是“近 30 天各品类销售额环比增长了多少” 进入查询解析层之后模型输出的 QueryIntent 大致是metrics 包含“销售额”和“环比增长”dimensions 是“品类”filters 是“近 30 天”query_type 是“comparison”。这里 “环比”会被解析成需要两个时间窗口做对比不是简单的聚合。接着进入 Schema 检索层系统用“销售额”“品类”“环比”等关键词在目录里做混合检索。召回结果里dws_sales_daily和dim_category分数靠前dws_refund_daily因为指标定义里提到了 “销售额 支付金额 - 退款金额”也被一并召回。三张表之间目录里存在一条可 JOIN 路径dws_sales_daily.category_id dim_category.category_iddws_refund_daily.category_id dim_category.category_id。这条路径会被直接传给上下文组装层。上下文组装层最终构造的 Prompt 里只保留了这三张表的关键字段。尤其把“销售额”的口径说明写在了最前面如果用户问“销售额”优先考虑使用dws_sales_daily.sales_amount并扣除dws_refund_daily.refund_amount。SQL 生成层看到这个明确提示后生成了一条大致如下的 SQLSELECT c.category_name, SUM(CASE WHEN d.sales_date date_sub(current_date, 30) AND d.sales_date current_date THEN d.sales_amount ELSE 0 END) AS cur_sales, SUM(CASE WHEN d.sales_date date_sub(current_date, 60) AND d.sales_date date_sub(current_date, 30) THEN d.sales_amount ELSE 0 END) AS prev_sales, (SUM(CASE WHEN d.sales_date date_sub(current_date, 30) AND d.sales_date current_date THEN d.sales_amount ELSE 0 END) - SUM(CASE WHEN d.sales_date date_sub(current_date, 30) AND d.sales_date current_date THEN r.refund_amount ELSE 0 END)) / NULLIF(SUM(CASE WHEN d.sales_date date_sub(current_date, 60) AND d.sales_date date_sub(current_date, 30) THEN d.sales_amount ELSE 0 END) - SUM(CASE WHEN d.sales_date date_sub(current_date, 60) AND d.sales_date date_sub(current_date, 30) THEN r.refund_amount ELSE 0 END), 0) - 1 AS mom_growth FROM dws_sales_daily d JOIN dim_category c ON d.category_id c.category_id LEFT JOIN dws_refund_daily r ON d.category_id r.category_id AND d.sales_date r.refund_date GROUP BY c.category_name;这条 SQL 虽然看起来有点长但每一部分都能从目录信息里找到出处。这不是模型灵光一现而是数据目录每个层喂足了上下文后的自然结果。如果没有退款表信息模型大概率不会想到扣减退款如果没有品类维度表的 JOIN 路径模型可能就会用category_id直接分组返回一堆 ID 而不是名字。4.3 如果缺了数据目录这个查询会出现哪些典型错误我把上面这个查询里的数据目录信息故意拿掉直接给模型一个空白的 schema让它生成 SQL。结果基本可以预料第一模型可能自己造一个sales_summary表字段名照抄自然语言里的“销售额”SQL 一执行就报错第二如果给模型足够多的表它可能随机选择一张名字里带 sales 的表但不知道sales_amount和refund_amount的关系生成的销售额偏大第三模型可能直接对sales_amount做环比但不知道要扣退款最后报表数字对不上财务口径。这些错误都不是 SQL 语法层面的语法都对甚至 EXPLAIN 都能跑通但结果就是错的。这也是为什么我总说 Text-to-SQL 是一个“数据问题”多于“模型问题”的场景。你给模型的上下文越接近真实业务口径模型生成正确 SQL 的概率就越高。上下文质量是由数据目录决定的。5. 我踩过的坑与排查技巧数据目录接入 Text-to-SQL 的避坑指南最后分享一些真金白银换来的经验。这一节不是理论都是我实际部署和运营过一个 Text-to-SQL 平台之后踩出来的坑以及对应的排查思路。5.1 坑1把整个数据目录全量塞给模型Token 爆掉效果反而更差第一个版本我觉得数据目录信息丰富是好事于是把全表的字段说明、表关系一次性拼进 Prompt想给模型最大上下文。结果 Prompt 长度常常超过模型限制即使没超生成质量也明显下降因为大量无关字段干扰了模型判断。后来我改成两层筛选先用关键词和向量召回 Top 10 表再按字段与用户意图的相关度做重排最后只保留 Top 8 个字段进 Prompt。效果立刻提升错误率大概降了三分之一。这背后的道理其实是注意力机制的问题。模型上下文越长它对每一个词的注意力就越分散。与其给它一堆用不上的信息不如把最相关的信息磨得更细。数据目录在这里的作用不是“越多越好”而是“该出现的时候出现不该出现的时候别打扰”。5.2 坑2schema 描述不统一模型学会了“猜”而不是“看”最开始我们目录里字段描述是各业务线自己填的有人写“金额”有人写“订单金额”有人写“支付金额元”。模型经常搞混。后来我们干脆做了一个字段描述清洗和补全流程对每个核心字段先用 LLM 生成多个候选描述再由数据治理同学审核确认。这个流程跑完字段描述质量上去了SQL 生成的准确性也明显提升。特别要提醒字段描述不要出现歧义词比如既说“含税”又不说税率模型不知道要不要做价税分离。一个很小的例子目录里有一个字段叫amt描述是“金额”。模型问都没问直接当销售额用。后来我们改成“订单实付金额单位为元含平台优惠不含退款”模型再生成 SQL 时就会自动到退款表里做扣除。描述越精确越能减少模型自己脑补的空间。5.3 坑3字段有血缘但模型不会用必须在目录里预计算 join_path我们的数据血缘系统很完善两张表之间明明有 ETL 加工关系但模型并不知道它可以直接 JOIN。因为血缘是“A 表经过任务加工生成 B 表”不是“B 表可以 JOIN C 表”。我最早以为模型能理解血缘后来发现完全不行。最后我在目录里单独维护了一张可 JOIN 关系表把常见查询会用到的关联关系都提前算好并且附带连接字段和说明喂给模型它才会用。你可以把血缘理解成“族谱”把可 JOIN 关系理解成“同事关系”。族谱能说明谁是谁加工出来的但没法直接告诉你谁跟谁能一起开会。数据目录里这两种关系都需要有缺少哪一种SQL 生成的关联逻辑都会出问题。5.4 排查技巧给每一层单独打分不要只看最终准确率很多团队评估 Text-to-SQL 只看一个指标生成的 SQL 和标准 SQL 是否一致。这个指标太粗了链路那么长任何一个环节出错都会导致最终不一致。我更习惯给每一层单独打分。查询解析层看意图识别的准确率Schema 检索层看召回的 Top 5 表里是否包含标准 SQL 用到的表上下文组装层看 Prompt 里是否包含了标准 SQL 需要的全部字段SQL 生成层看给定正确上下文时SQL 语法通过率是多少。这样分阶段评测能快速定位系统短板。比如如果 Schema 检索层召回率只有 60%那后面模型再强也没用该做的是优化目录检索而不是调 Prompt。如果召回率有 90% 但 SQL 准确率只有 70%那问题更可能出在 Prompt 模板或者模型选择上。我用这个方式排查很多之前怎么调都调不好的问题最后都找到了真正的原因。还有一个细节为了排查我强烈建议把每次失败的用户 query 和 trace 日志都攒起来。攒够几百条之后用聚类工具分一下类你会非常直观地看到系统到底挂在哪一层。要么是业务新词没有被目录收录要么是字段描述太模糊导致模型选错要么是复杂嵌套问题超出模型能力。针对每类问题单独解决比盲目换模型靠谱得多。数据目录和 Text-to-SQL 的对接不是一个“把元数据导入向量库就完事”的活。它需要你对每一层的职责、每一层的输入输出、每一层可能引入的错误都有清晰的把握。我自己的体会是只要数据目录的质量和检索逻辑做到位Text-to-SQL 的落地难度会小非常多。反过来如果目录本身一塌糊涂再强的模型也只能在垃圾信息里碰运气。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →