引言:VARCHAR(255)没告诉你的

数据库里有几百张表,每张表几十个列。

VARCHAR(255)告诉你它是字符串,INT告诉你它是整数,但这些只是物理类型。它们不回答真正重要的问题:

  • 这个VARCHAR是邮箱、手机号、还是普通文本?
  • 这个INT是年龄、金额、还是状态码?
  • nl 这个列名是什么意思?27 在这个上下文里是年龄吗?

人工标注可以解决,但太累。规则匹配可以覆盖常见情况,但总有意外。如果有几百张表,每次业务变动就要重新标一遍,这件事就变得不可持续。

以前工作时遇到过这个需求,当时没能力做。现在会了,就试试。

韵染流光证明了我能从零训练一个小语言模型。那么这个能力能不能迁移到别的问题上?数据库列的语义识别,恰好是个合适的验证场景:

  • 类型有限(32种就能覆盖大部分情况)
  • 数据可以程序化生成(不依赖人工标注)
  • 问题清晰(给列名和样本,判断语义类型)

这篇文章记录了这个验证过程——不只是验证能力的迁移,还在过程中发现了一些意料之外的东西。

三十二种语义:数据的生成

训练数据决定了模型能学到什么。但在开始之前,我需要先回答一个问题:能识别哪些类型?

答案不是"业务上需要什么",而是"我能找到构建方式的有多少"。我现在是想做什么做什么的状态,当然是找能用程序生成的类型了。

最终是32种——不多不少,因为这是我能程序化生成的全部。

这32种类型在数据构建方式上自然分成了三类。

能程序构建海量数据的:Corpus类型

有些类型可以直接程序生成大量原始数据,且只有一种格式。

6种类型:姓名、用户名、邮箱、URL、User Agent、职业

特点

  • 可以生成几十万甚至上百万条原始数据
  • 没有格式变体(邮箱就是邮箱,不会有不同的书写方式)

示例

  • 用户名:user_2847, alice_chen, dev_tom123
  • 邮箱:zhang.wei@example.com, contact@company.cn
  • URL:https://api.example.com/v1/users, www.site.org/page

这些数据可以完全程序化产生,生成足够多的原始数据后,随机采样就能组装成训练样本。

直接生成多种变体的:Variant类型

有些类型可以现场生成样本,不需要预先准备原始数据文件作为中转。更重要的是,同一个语义有多种格式表达。

20种类型:手机号、身份证、银行卡、金额、日期、时间、坐标、车牌号、IP地址、性别、民族、教育程度、状态、优先级、布尔值、年龄、整数、浮点数、百分比

特点

  • 每次需要样本时直接生成,不需要中间态的原始数据文件
  • 同一语义有多种格式(这是关键)

示例:手机号

  • 11位连写:13812345678
  • 3-8-8分隔:138-1234-5678
  • 3-4-4分隔:138-1234-5678
  • 带国家码:+86 138-1234-5678

示例:日期

  • ISO格式:2024-10-30
  • 中文格式:2024年10月30日
  • 斜杠格式:10/30/2024
  • 点分隔:30.10.2024

这些变体是真实世界中会出现的各种书写方式。模型需要学会"无论格式如何,只要是手机号就识别为手机号"。

只能存储真实数据的:Seed类型

有些类型无法程序生成,只能保存真实数据作为种子。恰好,这些类型也没有格式变体问题。

6种类型:地址、小区、公司名称、医院、娱乐场所、商超

特点

  • 无法程序生成,只能存储真实数据
  • 每类需要15万数据用于训练,但像邮编这样的类型根本没那么多

使用方式
存在项目里作为种子库,每次生成样本时随机挑选若干条。

示例:地址种子库

北京市朝阳区建国路88号
上海市浦东新区陆家嘴环路1000号
广州市天河区天河路123号
...

生成样本时,随机挑选10条组成一个训练样本。


这三种分类不是预先设计的,而是在寻找"能构建的类型"时自然形成的。分类标准只有一个:数据怎么来。

列名|样本:输入的编码

模型需要输入,但"列名 + 数据样本"该怎么喂给它?

这是个具体问题。列名和数据样本是两种不同性质的信息:

  • 列名是描述phone_numberagefield_3
  • 数据是内容1381234567825北京市朝阳区

它们都是字符串,tokenization后都会变成token序列。理论上,就算直接拼成 phone_number13812345678139987654321501234123,模型也能从大量样本中学会"前面几个token是列名,后面的是数据"。

但我不想这样做。我喜欢明确的设计语义——列名和数据是不同维度的信息,就应该用不同的符号区分。这样在逻辑上更清晰,我也希望我的模型按这样的方式去学习。

两种分隔符的语义

最终的输入格式是:

列名|样本1<sep>样本2<sep>样本3...

比如:

phone_number|13812345678<sep>13998765432<sep>15012341234

| 的作用:分隔描述和内容。它告诉模型"左边是列名,右边是数据"。

<sep> 的作用:分隔样本。它告诉模型"这是不同的数据条目"。

这不是什么高深的设计,就是"不同维度用不同符号"的直接想法。但选择哪个符号有个约束:它们不能在语料本身中出现,至少不能严重干扰描述和内容的分割。

理论上,数据库的数据字段里什么都可能出现——包括 |。但列名不一样。

列名遵循数据库标识符的命名规范,通常只允许字母、数字、下划线,有些数据库支持更多符号,但 | 这种竖线符号?几乎不会有人用,数据库系统大多也不支持。就算支持,谁会给列起名叫 user|name

所以 | 作为列名和数据的分隔符,在实际场景中是足够安全的。

至于 <sep>,它是个特殊token,不是单个字符,在32种类型的数据里都不会出现。

这个选择不是绝对完美——如果真的遇到极端情况,数据里包含了 <sep> 这个字符串,那个数据会被错误分割成多个样本,可能影响识别。但手机号、邮箱、地址、公司名...这些常见类型里,<sep> 四个字符连续出现的概率极低。这是个理论上存在、实践中几乎不会遇到的问题。

在"完美但复杂"和"足够好且简单"之间,我选了后者。

为什么是Multi-label?

有些列名没什么信息量:

item|1,2,1,4,5
field_5|23,45,67,12,89
col_3|0.5,0.2,0.8,0.1,0.9

item 是什么?field_5 又是什么?只看列名,你猜不出来。

但数据样本能提供线索。1,2,1,4,5 可能是:

  • INT(整数)
  • AGE(年龄)
  • PRIORITY(优先级:低、中、高...)
  • STATUS(状态码)
  • PERCENT(百分比,只是忘了加%)

这不是模型出错,而是这个列本身就有多种解释的可能性。列名不提供信息,数据样本又能匹配多个模式,那它就应该被标记为多个类型。

所以模型架构用的是Multi-label分类,而不是单标签。每个样本可以有0到32个标签,标签间没有互斥关系。

这样的设计也符合现实:数据库里确实存在"列名随便起,只有看数据才知道是什么"的情况。与其强行让模型猜一个,不如让它老实说"这几种都有可能"。

从16k到960k:递进中的认知转变

16k:太简单了

训练开始前,我按照韵染流光的经验,配了一个中等规模的模型:512维,8层encoder,37.3M参数。

然后开始训练。第一个epoch结束,Loss曲线正常下降,各项指标都在上升。我打开日志看验证集结果:

Score: 0.99712
F1: 0.99715

99.7%?第一个epoch?

我以为是代码写错了。检查了一遍:数据生成没问题,样本构建没问题,训练循环没问题。再看梯度监控,正常下降。Loss也在正常收敛。

这就是真实结果。

第二个epoch,指标几乎不动了。微调了学习率,继续训练几个epoch,最终稳定在99.72%左右。

这时我意识到:这个任务,比我想象的简单太多了。或者说,37.3M参数对这个任务来说太多了。

160k:还在太快

既然16k这么简单,那就加数据量验证一下。160k样本,每类5000个。

但我当时的思路还停留在"数据量大了应该给更多容量,虽然任务简单,但10倍数据量给两倍参数应该可以吧",所以用了更大的配置:768维,12层,76.0M参数。

训练开始。两个epoch后:

Score: 0.99634
F1: 0.99641

又是两个epoch就接近99.7%。

然后继续训练,慢慢爬到99.7%以上。梯度正常,Loss正常,没有过拟合的迹象。

但我看着这个学习曲线,感觉不对。

不是说结果不好 —— 99.7%已经很好了。而是这个"两个epoch快速到达,然后慢慢磨"的过程让我感觉:模型容量还是太大了,它在用过剩的参数记住数据的细节。

如果参数量合适,学习曲线应该是持续平滑上升,而不是"快速达到90%,剩下的10%慢慢磨"。

我开始降参数。768维降到512维,12层降到8层。再训练,结果几乎一样。

继续降。512降到128维,8层降到2层,3.1M参数。第一个epoch还是直接90%+。

这让我停下来,开始思考一个更根本的问题:为什么这个任务这么简单?

钻石与碳:信息量的差异

我把两个项目的语料拿出来对比。

韵染流光的语料是这样的:

泛粉的暗纯热情红
color 774 165 12056
chroma $p 0.1
lightness $p -0.1
color 774 165 12056
mix $p $p 0.1

它需要理解:

  • "泛粉"是色相调整还是混合?
  • "暗纯"里,"暗"影响明度,"纯"影响饱和度
  • 这些修饰词的组合顺序,最终对应一系列操作的顺序

列语义的语料是这样的:

周岁|44Y<sep>82Y<sep>79Y<sep>45Y<sep>29Y
age|98 周岁<sep>87 周岁<sep>49 周岁
prop_4|年龄:74<sep>年龄:7<sep>年龄:85
field_5|age: 66<sep>age: 109<sep>age: 79
shuju1|2<sep>54<sep>105<sep>90<sep>3

它需要理解:

  • 列名可能有意义(age、周岁),也可能没意义(prop_4、shuju1)
  • 数据可能有明显特征("Y"后缀、"周岁"),也可能只是数字
  • 同样的数字"2",在不同上下文里可能是年龄、状态、优先级、百分比

但本质上,这就是模式匹配。有些模式很明显("Y"后缀),有些需要结合上下文(纯数字+列名),但都是"看到X就判断Y"。

韵染流光的任务是组合逻辑:多个修饰词怎么组合,顺序是什么,每个词对颜色的影响如何叠加。列语义的任务是模式识别:这一组特征对应哪个类型。

就像钻石和碳。化学成分一样,但结构复杂度完全不同。

后来项目收尾时我实际算了一下两个项目的参数效率。韵染流光用了60.6M参数训练5.3M个token,每个token平均需要11.4个参数。数语觅类用4.2M参数训练107M个token,每参数对应25.5个token。

差距约291倍。

但这个差距不只是"信息密度"。它包含了至少三个因素:

  • 架构复杂度:encoder-decoder 比 encoder-only 需要更多参数(decoder有cross-attention)
  • 任务类型:生成任务需要学习内容+顺序+格式,分类任务只需要特征→标签的映射
  • 信息密度:组合逻辑确实比模式识别复杂

如果只看信息密度,颜色语料可能是列语义的10-20倍。但即使是"模式识别",如果参数量不对,学习过程也会不正常。

960k:终于正常了

既然160k用3.1M参数还是收敛太快,那960k该用多大参数?

我回到社区,找到了Chinchilla论文里的"token参数比"概念。社区的经验主要针对大规模生成模型,但思路可以借鉴:语料信息密度越高,每个token需要的参数越多。

基于"钻石与碳"的分析,我试了一下把token参数比设为25(也就是每参数对应25个token)。这个数值比Chinchilla建议的20要宽松,但考虑到这是分类任务,应该合理。

960k样本,每个样本平均111.5个token,总共约1.07亿token。token参数比25,目标参数量约428万。

然后是模型结构。任务不复杂,1层encoder就够了。Transformer参数量由 12 × 层数 × 维度² 决定,加上embedding层(30000词表),反推出来:128维,4.2M参数。

开始训练。

这次不一样了。

Loss平滑下降,Score稳定上升。不是第一个epoch就到99%,而是持续学习了31个epoch,从90%+慢慢爬到99.72%。

训练曲线终于正常了。

不是"快速到达然后停滞",而是"持续学习直到收敛"。这才是参数量和任务复杂度匹配时应该有的样子。

4.2M参数,圆了项目开始时"每类30k样本"的梦。更重要的是,它验证了"从信息量反推参数"的思路。

从数据反推架构

这个过程让我形成了一个新的思路:不是先定架构再找数据,而是从数据反推架构。

核心变量有三个:

  1. 语料规模 → 总token数(样本数 × 平均token长度)
  2. 信息密度 → token参数比(越复杂越小)
  3. 任务复杂度 → 层数(需要几次特征提炼)

有了这三个,就能反推出合适的维度。

对于encoder-only架构,参数量的组成是:

总参数 = 词表大小 × 维度 + 12 × 层数 × 维度²

其中"12"来自transformer的参数分解:

  • Self-attention: 4×d_model²
  • Feed-forward: 8×d_model²

已知总参数量(= 总token数 / token参数比)、词表大小、层数,就可以通过一元二次方程反推维度。

我把这个逻辑写成了函数1。但需要注意:

  • 这个公式只适用于encoder-only架构
  • Encoder-decoder架构的系数不同(decoder有cross-attention)
  • Token参数比这个值混合了架构、任务、信息密度三个因素

所以这不是"标准做法",而是这个项目里摸索出来的经验。它让我在小模型训练上不再拍脑门定参数,但适用范围有限。更通用的参数估算方法,还需要更多实践验证。

尾声:验证与展望

最终结果

测试集上的数字是99.7%准确率,4.2M参数。

这个结果说明任务确实简单——32类语义识别,给定列名和样本,模式足够明显。小模型就能学会。

但"简单"不是贬义。它意味着这个问题有清晰的边界,可以用确定的方式解决。不需要几百M参数,不需要海量算力,一个4M的模型就能在实际场景中用起来。

这就够了。

能力的迁移

韵染流光证明了我能从零训练小语言模型。数语觅类证明了这个能力可以迁移。

从颜色理解到列语义识别,问题完全不同:

  • 颜色是生成任务,列语义是分类任务
  • 颜色需要理解修饰的组合逻辑,列语义需要识别数据的模式特征
  • 颜色的DSL有递归嵌套,列语义的输入格式平铺直叙

但底层的训练框架、数据生成思路、模型架构设计——这些东西是通用的。搭过一次,第二次就轻松了。

更重要的是对"信息量"的认知。如果没有经历从200M到4.2M的认知转变,我会继续拍脑门定参数,继续在"模型是不是太小了"和"会不会过拟合"之间摇摆。

现在我知道:先看数据,再定架构。不是所有任务都需要大模型。

下一步

单列识别已经能用了。但它还不够聪明。

比如:

  • 两个列:longitudelatitude,它们单独识别都是坐标
  • 但如果一起看,它们是经纬度对——这是表级别的语义

或者:

  • user_idcreated_by,从列名和数据看,都是整数或字符串
  • 但如果知道它们在同一张表里,就能推断:这可能是关联列

这些是阶段2的目标:表级别的语义理解。不只看单列,还要看列之间的关系。

但这次先到这里。单列识别已经验证了能力的迁移,也发现了意料之外的东西。

足够了。


💾 项目资源

项目代码GitHub - Tabular Sense


  1. 参数估算方法: 从数据反推模型架构:一个小模型训练的经验公式。这篇文章详细推导了从总token数、信息量、层数反推模型维度的完整过程,包括transformer参数分解公式和一元二次方程求解。但这个方法目前只在encoder-only架构上验证过,适用范围有限。