做Text-to-SQL训练数据的人,最崩溃的往往不是模型,而是数据本身。
我手里的原始SQL来自好几个渠道:一部分是业务系统里捞出来的历史查询,一部分是公开数据集,还有一部分是同事攒下来的分析脚本。看起来都是SQL,实际乱得跟菜市场一样——有的全是注释,有的关键字大小写混杂,有的明显是AI写出来但从来没执行过的伪SQL,还有大量只差一个空格就完全相同的重复样本。之前我都是手动清洗,每来一批数据就配一次正则,弄完一批下一批又不一样,后来实在顶不住,决定把这些活整理成一条可复用的DataFlow Pipeline,把所有"从原始SQL到高质量训练样本"的处理环节一次性固化下来。
这篇文章就是这套方案的完整复盘。我会讲清楚我为什么非要建Pipeline而不是堆脚本,每个节点到底干什么,pipeline脚本语法怎么写,以及我在验证环节踩过的几个实实在在的坑。如果你正在做大模型微调数据工程,或者单纯想把手头混乱的SQL数据处理流程规范化,这篇文章应该能帮你少走不少弯路。
1. 为什么我不再手动清洗SQL,而是专门做一条Pipeline
1.1 一开始我以为这只是"脏数据"问题
最开始接到这个需求时,我的想法很简单:给我一批SQL,我清洗干净,转成JSONL,交给模型训练。听起来就是"正则匹配加字符串替换"的活。
结果第一周就翻车了。倒不是说清洗不了,而是"清洗"这件事本身没有尽头。你今天处理了关键字大小写,明天数据源里多了带中文注释的版本;你刚搞定去重,发现两个SQL语义相同但写法不同,哈希根本对不上;你辛辛苦苦验证了一批SQL能跑通,结果换了个目标数据库方言,全挂。每个问题都是加一个if、加一个正则就能解决,但每加一个if,下一次处理新数据时又冒出新情况。手动处理脚本越写越长,却越来越不敢用,因为你根本不知道它会在哪一行上猝死。
后来我意识到,问题不在数据脏,而在于整个处理过程没有被结构化。清洗、标准化、去重、验证、格式化,这些环节混在一起,每次执行都从头跑到尾,中间结果不落盘,参数不配置化,想复现一批结果只能靠运气。这不是工程方案,这是碰运气。
1.2 Text-to-SQL训练数据对SQL文本有哪些硬要求
想明白"为什么"之前,得先弄清楚"要什么"。我根据后来模型训练的实际表现,整理出五条硬指标:
| 指标 | 为什么重要 | 不达标的典型表现 |
|---|---|---|
| 可执行性 | 模型学的SQL如果跑不通,学到的只是文本样式,不是查询逻辑 | 生成的SQL看着像模像样,一执行就报错 |
| 方言统一 | 训练数据方言混杂,模型输出也会方言混杂 | SELECT TOP和LIMIT混用,部署时哪个库都跑不顺 |
| 格式一致性 | 训练数据关键字大小写混乱,模型生成时也会跟着乱 | 输出SQL风格漂移,代码评审无法通过 |
| 类别多样性 | 全是单表简单查询,模型学不会复杂JOIN和子查询 | 一遇多表查询就乱写WITH子句 |
| 难易配比 | 简单SQL太多,模型倾向走捷径;复杂SQL太多,模型基础不稳 | 测试集上简单题反而过拟合,复杂题完全崩溃 |
这五条单独看都不难,但组合在一起,如果没有一条管线而是靠人肉维护,基本不可能稳定交付。所以我决定把整套流程节点化、配置化、可回溯化,这就是DataFlow Pipeline的起点。
1.3 手写脚本和Pipeline方案的本质区别
有一次我为了整理2000条SQL,写了个临时脚本,跑完输出一个JSON文件,第二天发现有个清洗规则写错了。按修复后的规则,我得把所有2000条重新跑一遍。听起来没什么,但那2000条是从5万条原始数据里筛出来的,重新跑意味着前面所有过滤逻辑也要重来。三分钟后我得到了同样的5万条原始数据,但中间所有的临时筛选结果都没了——因为我没有把中间状态缓存下来。
这就是手写脚本和Pipeline的本质区别。Pipeline的核心思想不是"写更长的脚本",而是"把处理过程拆成独立节点,每个节点的中间结果都是可复现、可缓存、可单独回放的一等公民"。我在第一条Pipeline里的约定是:
- 每个节点只做一件事,输入输出都有明确schema;
- 节点中间结果统一落盘,下次运行如果上游没变就直接复用;
- 节点参数全部放配置文件,不放代码里;
- 任何节点出错,只需要重跑该节点及其下游,不需要从头开始。
这套约定听起来简单,但它直接改变了处理数据的方式。原来我最怕的是"改一条规则全部重跑",现在最多只需要重跑两个节点,成本低到可以忽略。
2. 我的DataFlow Pipeline跑了哪些事:节点拆解与职责划分
2.1 六个节点一眼看完
整个Pipeline我拆成了六个节点,每个节点对应一个独立的数据处理阶段。下面这张表是它们各自的职责和输入输出:
| 节点 | 输入 | 输出 | 核心动作 |
|---|---|---|---|
| collect_source | 多来源原始SQL | source_records | 扫描目录、读取数据库日志、统一字段格式 |
| parse_dialect | source_records | parsed_records | 方言识别、语法解析、拆出AST信息 |
| clean_normalize | parsed_records | normalized_records | 去注释、统一关键字、格式化、补分号 |
| de_duplicate | normalized_records | deduped_records | 哈希去重、相似度聚类、保留策略 |
| validate_execute | deduped_records | validated_records | 离线执行验证、超时控制、错误分类 |
| format_output | validated_records | train_samples | 组装JSONL、生成指令、输出训练集 |
每个节点的输出都是标准化的RecordBatch,字段名在项目开始时统一约定。比如原始SQL字段固定叫raw_text,标准化后的固定叫standard_sql,最后训练样本字段固定叫instruction、input、output。字段名统一这件事极其重要,它让每个节点之间只依赖schema不依赖具体实现,后面替换任何节点都不影响全局。
2.2 每个节点为什么这么设计
这个拆分顺序不是随便排的,每一条都是踩过坑之后定下来的。
collect_source放在最前面,是因为数据源是真的杂。有的是.sql文件,有的是CSV导出,有的从日志接口直接拉。我做的第一件事不是清洗,而是把不同来源的数据全部转成同一种内部格式——保留source_id、source_path、raw_text、collect_time四个字段。这样后续所有节点都只面对一种输入,不会因为数据源格式不同而写一堆分支判断。
parse_dialect排在清洗之前,是因为如果你不知道一条SQL原来是哪个方言,后面的标准化就无从下手。比如LIMIT和TOP功能相似但方言不同,#注释在MySQL里合法在PostgreSQL里却不合法。我用的解析工具是sqlglot,它能解析二十多种方言并转成统一的AST。这一步还有一个额外价值:解析失败的SQL会在这一步直接被标记为syntax_error,不需要浪费后面的计算资源。
clean_normalize放在解析之后,是因为标准化规则可以基于AST来做,而不是基于文本字符串硬切。比如关键字统一小写、字符串统一保留原样、多余空白压缩、注释删除——这些操作在文本层也能做,但只有基于AST才能保证不会误伤字符串字面量,比如SELECT 'Hello -- World'这种内容里面的注释符号不能删。
de_duplicate在这个位置也是刻意的。如果放在清洗之前去重,select a from t和SELECT A FROM T会被当成两条完全不同的SQL;如果放在验证之后去重,意味着你得为大量重复SQL付费跑验证。清洗之后再提权,去重的精度和效率都刚好合适。
validate_execute的职责范围比想象中窄——它只判断"能不能执行""执行是否超时""错误类型是什么",不判断"查询结果对不对"。结果正确性校验需要真实业务数据做基准,那个成本太高了,我在Pipeline里只做到可执行级别,并且把执行错误分成syntax_error、exec_error、timeout三类单独记录。结果正确性留到下一层人工抽检。
format_output最后做,是因为所有清洗、去重、验证都在前面完成了,这个节点只需要做两件事:组装成训练样本格式,生成自然语言指令。它最轻松,但也是后续扩展性最强的节点。
2.3 Pipeline脚本语法:配置驱动代替硬编码
说到pipeline脚本语法,我用的不是"写一个大for循环把每一步串起来"那种过程式写法,而是"定义节点和依赖关系,再由调度器决定执行顺序"的声明式写法。下面是一个简化版的配置:
pipeline: name: text2sql_data_pipeline version: "1.0.0" cache_dir: ./cache nodes: - id: collect_source type: scanner params: input_paths: - ./data/raw/sql_files - ./data/raw/sql_logs file_pattern: "*.sql" - id: parse_dialect type: sql_parser params: dialect: auto target_dialect: duckdb on_error: mark_and_skip - id: clean_normalize type: sql_normalizer params: lowercase_keywords: true remove_comments: true preserve_string_literals: true add_semicolon: true - id: de_duplicate type: deduplicator params: exact_hash: true similarity_method: simhash similarity_threshold: 0.85 - id: validate_execute type: sql_executor params: executor: duckdb timeout_ms: 500 max_connections: 10 - id: format_output type: jsonl_writer params: output_path: ./output/train_samples.jsonl sample_format: instruction每个节点通过type指定实现类,通过params传入参数。调度器按nodes里的顺序执行,但解耦点是:任何节点都可以单独执行,只要指定--node-id参数,调度器会自动跳过上游节点,直接读取缓存的中间结果。
为什么这么设计?有三个现实原因。第一,清洗规则几乎是天天变的,今天你去掉一个关键字大小写规则,明天你加一个JOIN重写规则,如果这些硬编码在Python函数里,每次都要改代码走部署流程;放到配置文件里,改完直接重跑验证节点就行。第二,配置驱动的管线可以记录每次运行的参数快照,下次想复现N天前的数据结果,直接把当时那份配置调出来即可。第三,团队里不是每个人都有权限改代码,但让数据同事改YAML文件是完全没有心理负担的。
3. 实操:用DataFlow搭建Text-to-SQL管线的关键步骤与代码骨架
3.1 环境准备与项目结构
搭建这条Pipeline我用了几个关键依赖,全部开源免费:
- Python 3.9+:整个Pipeline的运行环境;
- sqlglot:方言解析和标准化,这是处理SQL文本最重要的库;
- duckdb:离线执行验证,轻量、免安装、支持大部分主流SQL语法;
- DataFlow核心库:负责节点调度、中间结果缓存、断点续跑;
- PyYAML:读取pipeline配置。
项目的目录结构长这样:
text2sql_pipeline/ ├── configs/ │ └── pipeline_v1.yaml ├── nodes/ │ ├── collect_source.py │ ├── parse_dialect.py │ ├── clean_normalize.py │ ├── de_duplicate.py │ ├── validate_execute.py │ └── format_output.py ├── core/ │ ├── context.py │ └── cache_manager.py ├── data/ │ ├── raw/ # 原始SQL输入 │ ├── cache/ # 中间结果落盘 │ └── output/ # 最终训练样本 └── run_pipeline.pyrun_pipeline.py是入口,它干的事情是:读配置、实例化节点、按顺序执行、把每个节点的输出写到cache目录。看起来简单,但"cache目录"这个设计非常重要,它是整个Pipeline能断点续跑的基础。
3.2 接入原始SQL:多来源统一入口
collect_source节点是我最不想写花哨代码的地方,因为数据源会不断变化,我只需要保证一点:无论从哪里来,最终都输出统一的RecordBatch。
以下是一个简化版的实现思路:
# nodes/collect_source.py import os from pathlib import Path from typing import Iterator, Dict, Any def collect_source(config: dict) -> Iterator[Dict[str, Any]]: """扫描指定目录下的SQL文件,逐个yield统一格式的记录。""" input_paths = config["input_paths"] for path in input_paths: p = Path(path) if p.is_file(): yield from _read_sql_file(p) elif p.is_dir(): for f in sorted(p.glob("**/*.sql")): yield from _read_sql_file(f) def _read_sql_file(filepath: Path): """读取单个SQL文件,按分号切分成独立语句。""" with open(filepath, "r", encoding="utf-8", errors="replace") as f: content = f.read() # 按分号切分,注意过滤空语句 for idx, statement in enumerate(content.split(";")): statement = statement.strip() if not statement: continue yield { "source_id": f"{filepath.name}_{idx}", "source_path": str(filepath), "raw_text": statement, "collect_time": datetime.now().isoformat(), }这里有个细节我会特别提醒:不要一次性把整个SQL文件全部读入内存再处理,而是用yield做成生成器。我之前试过一次性加载几万个SQL文件,内存直接飙到十几个GB,机器直接卡死。生成器配合分批写入,内存占用能控制在几百MB以内。
3.3 清洗与标准化:净化训练文本的关键一环
clean_normalize是整个Pipeline里最需要细抠的节点。我总结下来,这一步至少要做四件事:去注释、压缩空白、统一关键字大小写、补全结尾分号。
直接用sqlglot的AST来做,比正则靠谱得多。我第一次用正则去注释时,把SELECT '-- not a comment'这种字符串内容也给删了一半,后来改成基于AST处理才彻底解决。
# nodes/clean_normalize.py import sqlglot def clean_normalize(record: dict) -> dict: """基于AST做标准化,避免误伤字符串字面量。""" try: # 解析SQL并转成目标方言 sql_ast = sqlglot.parse_one(record["raw_text"], read=record.get("dialect", "duckdb")) normalized = sql_ast.sql(dialect="duckdb", pretty=False) except Exception as e: record["standard_sql"] = None record["normalize_error"] = str(e) return record # 压缩连续空白 normalized = " ".join(normalized.split()) # 统一补分号 if normalized and not normalized.endswith(";"): normalized += ";" # 关键字统一小写 normalized = normalized.replace("SELECT", "select").replace("FROM", "from") record["standard_sql"] = normalized record["normalize_error"] = None return record我在这里把standard_sql和normalize_error两个字段同时写进RecordBatch。为什么保留错误字段?因为我们不希望在标准化失败的记录上直接丢弃,而是把它标记出来,后续做训练数据质量分析时会用到。宁可多留一条带错误标记的数据,也不要让Pipeline默默吞掉异常。
还有一个小技巧:清洗后保留一份raw_text原始内容,千万不要覆盖。训练阶段某个样本出问题了,你要能溯源到原始输入,否则排查数据泄漏时会非常被动。
3.4 去重与相似度过滤:让模型学不到重复模式
SQL数据集的重复问题比普通文本严重得多。同一个业务查询,可能被100个人保存了100个版本,只是变量名不同、注释不同、格式不同。如果不做去重,模型训练时会被这些重复样本强加权,导致模型对某些常见写法过度自信,泛化能力反而下降。
我的去重分两层。第一层是精确哈希去重,最简单也最粗暴,直接对standard_sql算SHA256,完全相同就丢弃。第二层是相似度去重,用simhash计算文本指纹,再算Jaccard相似度,超过0.85就认为是近似重复,保留其中一条。
保留策略上,我默认保留"复杂度更高"的那条,规则是:字符数更多、包含的子查询更多、JOIN数量更多,排在前面。原因是训练数据宁可要复杂一些的样本,这样模型见过的边界情况更多。如果两条SQL复杂度差不多,就保留带注释的那条——注释里有时候会包含表字段的业务含义,对模型理解上下文有帮助。
相似度去重有个坑:simhash对超短SQL(比如SELECT 1)特别敏感,任何改动都会导致相似度直接掉到0.8以下,误杀率很高。我的处理方式是:字符数小于30的直接跳过相似度去重,只做精确去重,让超短SQL自然保留多样性。
3.5 可执行性验证:不能只看"解析成功"
解析成功不等于SQL能跑。我见过太多样例,语法检查一点问题没有,一执行就报"table not found"或者"column does not exist"。原因通常是:SQL里引用了不存在的表名、字段名,或者表名在目标库里确实存在但字段名拼写错了。
我的验证节点用DuckDB做离线执行器,好处是零依赖、单文件、启动快,适合批量验证。核心代码如下:
# nodes/validate_execute.py import duckdb from contextlib import contextmanager @contextmanager def get_connection(pool): """从连接池取连接,用完自动归还。""" conn = pool.pop() try: yield conn finally: pool.append(conn) def validate_batch(records: list, config: dict) -> list: """批量执行SQL,记录执行结果和错误类型。""" conn_pool = [duckdb.connect(":memory:") for _ in range(config["max_connections"])] timeout_ms = config.get("timeout_ms", 500) results = [] for record in records: sql = record.get("standard_sql") if not sql: record["quality_flag"] = "NORMALIZE_ERROR" results.append(record) continue with get_connection(conn_pool) as conn: try: # 设置超时 conn.execute(f"SET timeout={timeout_ms}") conn.execute(sql) record["quality_flag"] = "VALID" record["exec_error"] = None except duckdb.Error as e: record["quality_flag"] = "EXEC_ERROR" record["exec_error"] = str(e) except Exception as e: record["quality_flag"] = "TIMEOUT" if "timeout" in str(e).lower() else "UNKNOWN_ERROR" record["exec_error"] = str(e) results.append(record) return results这个节点执行完,每条记录都会带上一个quality_flag字段,我用它来过滤最终进入训练集的数据:只保留VALID的记录。但EXEC_ERROR和TIMEOUT的记录我不会直接删除,而是单独导出到一个error_analysis目录,定期看一眼——很多数据问题就藏在这些错误里。
4. 数据质量问题排查:我在验证环节踩过的坑
4.1 那些"能解析却执行失败"的SQL
第一版验证节点上线后,我跑了一批数据,结果发现一个诡异的现象:有大概4%的SQL在sqlglot里解析完全正常,转成DuckDB方言也正常,但DuckDB一执行就报错。报错信息千奇百怪,最多的两类是"Catalog Error: Table with name X does not exist"和"Binder Error: Referenced column Y not found in table Z"。
第一类好理解,就是SQL里用到的表没有预先在DuckDB里注册。我的训练数据很多是从业务系统日志里来的,里面引用的表在本地验证环境里根本不存在。第二类就比较坑了,字段名拼写看起来一模一样但报不存在,后来排查发现是大小写问题:DuckDB默认把未加引号的标识符折叠成小写,而业务库里的字段是混合大小写,两者对不上。
这两个问题最终都不是靠改验证节点解决的,而是靠"标准表集合"。我给DuckDB预先建了一套标准Schema,把训练数据里所有可能出现的表都注册进去,字段名统一用小写加下划线风格。SQL在标准化阶段就把表名和字段名转换成标准Schema里的名字,验证才真正通过。这个过程工作量不小,但一旦做完,后面所有数据验证都会顺畅很多。
4.2 并发执行时的连接数问题
我第一版验证是每条SQL新建一个连接,跑完就断开。量小的时候没什么感觉,但处理五万条SQL时问题立刻暴露——底层DuckDB文件被频繁开关,每秒新连接数量上百,最终把整个本机的文件句柄和锁机制都给拖垮了,验证速度从每秒几十条直接掉到每秒几条。
解决方式就是我上面代码里的连接池。每个Worker拿一个连接,用完归还,不会被销毁重建。同时控制max_connections的数量,DuckDB的并发连接数并不是越高越好,我实测8到16个连接性能最佳,超过32个反而会因为锁竞争大幅下降。
另外有个小坑:DuckDB连接默认不开启多线程写同一个数据库文件,如果你用duckdb.connect("file.db")这种持久化连接,多个线程同时写入会报锁冲突。做验证时直接用:memory:模式,避免锁问题,反正我们不关心验证结果落盘。
4.3 慢SQL直接把整条管线拖挂
训练数据里偶尔会有几个特别离谱的SQL,比如三层嵌套子查询加多个窗口函数,执行起来要好几秒钟。如果没做超时控制,这几十条SQL就能把整条验证队列拖上几个小时。
我的处理是给每条SQL设置500毫秒超时,超时就标记为TIMEOUT,不再等待。这里要说一个原则:训练数据里并不是不应该有慢SQL,恰恰相反,复杂SQL往往是训练样本里最珍贵的部分。所以超时后我不直接删掉,而是把这类SQL放到一个单独的valid_slow集合里,后续专门处理——比如简化掉其中某些无关紧要的子查询,或者拆成多条短SQL再重新验证。
这个处理方式比一刀切删除要科学得多。纯粹删除会让模型对复杂SQL的接触变少,最后生成能力偏弱。保留并重写,既保证验证通过,又确保样本多样性。
4.4 编码、注释和方言识别:数据里的"隐形杀手"
这批坑最隐蔽,基本靠肉眼发现不了。最常见的三个:
第一是编码问题。业务日志导出的SQL文件不一定都是UTF-8,有些是GBK,有些在字段值里带着乱码字符。Python默认用UTF-8读文件会直接报错,我改用errors="replace"读取,保证不中断,但乱码字符仍然留在raw_text里。最后在清洗阶段统一做了一遍编码检测,非UTF-8的字段值直接替换成占位符。
第二是注释问题。中文注释和英文注释都还好处理,难的是注释里带着引号、分号、emoji,或者注释没有换行直接跟SQL写在一起。sqlglot对这种情况容忍度其实很高,但某些边缘情况还是会把后面的SQL一并当成注释解析掉。我的策略是:清洗阶段先移除注释,但把注释内容单独保存为sql_note字段,这个字段后面生成训练指令时有大用。
第三是方言识别错误。auto模式不是万能的,我遇到过一条SQL在MySQL里是合法的,但sqlglot自动识别成了PostgreSQL,转成DuckDB后在日期函数上出了偏差。后来我在parse节点加了一步"方言置信度判断",置信度低于阈值就把记录标记为DIALECT_UNSURE,由人工或更精细的规则兜底,不能因为一次自动识别错误就把整条数据报废。
5. 把合格SQL转成训练集:格式设计、指令生成与微调前检查
5.1 训练样本的格式:从RecordBatch到JSONL
验证通过的SQL并不会直接用于训练,还需要被组装成模型能理解的样本格式。不同微调框架对数据格式要求略有不同,但主流的Text-to-SQL任务基本都会落到下面两种格式之一。
第一种是简单指令格式,适合SFT:
{"instruction": "根据数据库schema,回答下面问题并生成SQL", "input": "统计已支付订单的数量。schema: orders(id, status, amount, created_at)", "output": "SELECT count(*) FROM orders WHERE status = 'PAID';"}第二种是带db_id的训练格式,适合多轮对话或者像SPIDER这样的基准评测:
{"db_id": "order_db", "question": "统计已支付订单的数量", "schema": "orders: [id, status, amount, created_at]", "sql": "SELECT count(*) FROM orders WHERE status = 'PAID'", "quality_flag": "VALID"}我建议一开始就把两种格式都生成,分别放到两个目录里。因为你不知道后面会接哪个微调框架,也不确定要跑什么基准测试,保留两个格式可以随时切换,不用重新跑Pipeline。
5.2 自动生成自然语言指令:同一个SQL可以有多问法
Prompt里只给一个裸SQL是不利于模型学习的。模型需要知道"这个SQL回答了什么问题"。所以format_output节点里最重要的事情是如何为SQL生成对应的自然语言提问。
如果每条SQL手写提问,那效率太低。我基于表元数据做了自动模板生成。比如看到SQL涉及表orders、字段status和PAID这个常量值,就可以生成:
- 统计已支付订单的数量。
- 订单表里状态为已支付的有多少?
- 从订单表中统计出支付成功的订单总数。
同一个SQL生成多条不同问法,训练数据就自动做了数据增强,模型对语义变化的泛化性会更好。但这里有一个提醒:自动生成只负责"搭骨架",最终质量必须靠人工抽检。句子不通顺的、表意错误的,会直接拖垮训练效果,宁可样本少一点也不要硬凑。
5.3 进微调前的三个检查:目标泄漏、Schema匹配、数据集划分
这是format_output之后、送进模型训练之前一定要做的事,漏掉任何一个都会影响最终效果。
第一个检查是目标泄漏。有些SQL引用的表,在input的schema里根本没出现,模型看到的是"凭空多出来的表",训练时模型学会的是"从上下文中猜表名",推理时却没有这个信息可用。我的排查方式是脚本自动比对AST里出现的表名和schema里给出的表名,有不一致的直接筛出来。
第二个检查是Schema匹配。Text-to-SQL类模型对schema变化特别敏感,训练集里的schema风格必须和推理时保持一致。我定的规范是:字段类型统一用字段名: 类型描述,多个字段用逗号分隔,表之间用分号分隔,不要一会儿大写一会儿小写。
第三个检查是数据集划分。去重逻辑不能只在单一批次内做,最终划分训练集、验证集、测试集之前,要再跑一次相似度去重,保证"同一语义的SQL不会同时出现在训练集和验证集里",否则验证集就失去了评估意义。我见过两次这样的问题,都是图省事直接用随机划分翻车,最后测试分数虚高,一上线立刻原形毕露。
5.4 后续还能扩展的玩法
这套Pipeline跑通之后,扩展空间其实很大。我目前已经规划了两个方向。
方向一是增加负样本生成。故意构造一些错误SQL样本,并附上"为什么错"的说明,让模型学会识别和纠正错误。这类数据对提升模型在真实场景里的鲁棒性帮助很大,但生成逻辑需要单独设计,不能拿现有正样本直接篡改,否则会污染。
方向二是接入人工审核队列。目前Pipeline输出的是全量训练集,但有些样本置信度不高,比如自动生成的指令语句不通顺、SQL里用了不常见写法等。我让Pipeline在输出时额外标注一个needs_review字段,人工审核队列只处理这些低置信度样本,能显著提高整体数据质量,又不会让审核人员淹没在海量样本里。
再往后,如果数据源是持续更新的,还可以把整个Pipeline做成定时任务,每天晚上自动从生产日志捞最新SQL,增量跑一遍,去重后并进训练集。这样模型微调用的数据就能保持新鲜,而不是一版数据用到翻车。
我在实际使用中最深的一点感受是:Text-to-SQL这条赛道上,模型结构再先进,数据环节偷懒都会在最终效果上还回来。Pipeline的价值不是让单批数据处理得更快,而是让"数据怎么处理的"这件事变得可复现、可审查、可改进。最后分享一个小技巧:在每个节点执行完,打印一行统计——原始多少条、清洗后多少条、去重后多少条、验证通过多少条。这行日志在调参和排查问题时简直是救命稻草,建议你也加上。