alitrack

duckdb-luajit:中文工单分类,在 SQL 里训练模型

电商客服每天几千条工单进来:退款、物流、技术故障、账号被盗、商品咨询。传统做法是每条工单调一次大模型 API 打标签——一条一条付钱,一条一条等。现在这事可以在 DuckDB 里本地解决:训练和预测都是一个 SQL 函数调用,数据从头到尾不出库。

一句结论:duckdb-luajit 新增了 ml/classifier 库,中文文本分类在 SQL 里就能训练和推理,不调 API、不需要 Python。

LOAD luajit;

-- 1. 安装库(duckdb-luajit-libs 扩展市场)
SELECT * FROM luajit_module(mode:='install', sql_name:='classifier');

-- 2. 训练:直接吃工单表
SELECT luajit_s('classifier', {op:'train', out:'/tmp/model.json',
    sql:'SELECT text, label FROM tickets WHERE label IS NOT NULL'});

-- 3. 预测:还是 SQL
SELECT text,
    json_extract_string(luajit_s('classifier', {op:'predict',
        model_path:'/tmp/model.json', texts:[text]}),
        '$.predictions[0].label') AS predicted
FROM tickets WHERE label IS NULL;

训练完得到一个 44KB 的模型文件。之后的预测是本地毫秒级的算术,不再碰任何 API。

● ● ●

为什么值得单独一说

DuckDB 生态里做文本分类,通常的路是 Python:把数据导出去,sklearn 走一遍,模型存下来,再写回数据库。数据要出门一次,环境要对齐一次,部署要多一个 Python 运行时。

这条链路不需要。数据从头到尾待在 DuckDB 里,训练和推理都是 SQL 里的一个函数调用。底下是这么一层层叠起来的:

SQL 层     classifier_train / classifier_predict / classifier_evaluate
             │
Lua 层     libs/ml/classifier.lua(388 行):参数拼装、JSON 往返、错误分支
             │
FFI 边界   C ABI(4 个导出符号,JSON 进 JSON 出)
             │
Rust 内核  classifier_capi(717 行):分词、TF-IDF、训练、校准、统计推断

Rust 内核编译成一个 757KB 的 cdylib,通过 LuaJIT FFI 挂进 duckdb-luajit。这个套路之前在这套库的 stats 类库里验证过(rng、stats),这次把它推到了机器学习:不用 Python,不用外部服务,SQL 一个函数就出模型。

classifier 四层架构

classifier 四层架构

● ● ●

中间发生了什么:一段真实的训练输出

训练不是黑盒,report 会把过程吐出来。这是一批中文工单(5 类:billing / shipping / technical / account / product)训练时实际输出的片段:

n_classes: 5
n_features: 706        ← 中英混合 bigram 词表,706 个特征
train_accuracy: 0.9    ← 训练集 90% 分类正确
calibration: auto 20%  ← 自动留出 60 条里的 12 条做校准,防止"训练分高=真的准"

706 个特征是 bigram 分词器从这批工单里学出来的。中文按字粒度切(「退款」「物流」各算特征),英文按词切,混着的句子两边都能吃到信号。这套分词是照着一个叫 jimothy 的 MIT 开源项目写的——一个 6 星小仓库,核心思路是把大模型"读一遍标注"的活,蒸馏成一个小分类头,本地批量推理。我们只取它的模式参考,约 85 行统计算法用 Rust 重写,链路和工程化是自己的。

● ● ●

概率不能瞎信:温度校准 + Clopper-Pearson 下界

这块是这类小模型最容易被忽略、也最不该被忽略的地方。

softmax 吐出来的 0.93 不等于「93% 把握」。小样本训练的分类器概率普遍过自信。所以训练时自动做两件事:

训练与校准流程

训练与校准流程

温度校准。留出的校准集上,用黄金分割搜索找一个温度系数 T,让预测分布和实际命中率对齐。过自信就调软,欠自信就调硬。校准后的概率才勉强能当概率用。

Clopper-Pearson 精确二项下界。模型推荐阈值时用的不是点估计,是保守下界。而且它知道自己什么时候没资格表态:阈值推荐要求校准集至少 30 条,上面那次训练校准集只有 12 条,report 里直接返回 insufficient_data,一个阈值都不推——宁可不出主意,不出张嘴就来的主意。这是从临床统计借来的口径:小样本上的「准确率 90%」,审稿人看的从来不是 90,是它的置信下界。数据量够的时候,输出长这样:

threshold
n_accepted
accuracy
cp95_lower
coverage
0
30
0.667
0.436
1.00
0.5
20
0.700
0.424
0.67
0.6
11
0.727
0.367
0.37

真实跑出来就是这副诚实相:50 条训练、30 条校准的小数据集上,点估计 72.7%,CP 下界 36.7%——你要对外承诺「至少 95% 正确」,照点估计选是自欺,照下界那一栏选才是承诺。这就是它跟「调个 API 看看概率」的差别:概率有口径,承诺有下界。

● ● ●

边界:它替不了大模型

706 个 bigram 特征的线性分类器,能力上限摆在那。实测里「我的账户被扣了两次款,要求退款」这种强信号句子分得很准,但「退款到哪里了」这种退款和物流语义纠缠的边界句,会合理地分错——这不是 bug,是这个量级模型该有的表现。

所以适用场景是:错误可承受的批量环节。工单预分桶、人审分流、粗筛。几万条积压工单的初筛用它(本地批量预测,不花钱),可疑的再交给大模型或人工。真要语义理解深的分类,还是大模型的活。

● ● ●

它在这个生态里的位置

同一套 duckdb-luajit 库里已经有一个 jev_ask:把文本发给模型服务,读回选项的概率分布,一次一条。这个 classifier 正好反过来:本地训练、批量预测、零网络。两个是搭档——先用 jev_ask 标几百条种子工单,喂给 classifier 训练,之后新工单用 classifier 初筛,拿不准的再走 jev_ask。标注的钱花在种子上,批量的活交给本地算术。

另一个常被问到的是我之前写的 duckdb-ml 扩展(35+ 算法:树、聚类、SVM、GBDT、ONNX 推理)。它吃的是数值特征——target 和 features 都是 JSON 数组,没有一个算法能直接吃文本字符串。所以两者不重叠:duckdb-ml 管数值表格上的建模,classifier 补上「原始文本进、标签出」这一段。要合也行——classifier 的 TF-IDF 向量化接 duckdb-ml 的分类算法——但 duckdb-ml 目前锁 DuckDB 1.5.4,版本节奏不同步,先各自跑着。

● ● ●

一个细节:AI review 抓出的四个真 bug

这个内核写完后,没直接上线。我让一个本地部署的 Qwen3.8-27B 模型对 Rust 和 Lua 代码做了一轮对抗 review(没有花一分钱云 API),它抓出四个严重问题,全部修复后才提交:

  1. 01
    NaN 安全的 argmax——权重里混进 NaN 会 panic 穿过 C ABI 边界
  2. 02
    模型文件强校验——坏文件要返回错误 JSON,不能越界崩溃
  3. 03
    cal 分支的 unwrap——异常输入直接崩
  4. 04
    predict 批量化——原来逐条重建词表,复杂度白涨一截

修完全绿:Lua 单测过、DuckDB 端到端 60 条工单预测零错分。代码已开源,链接在文末。

● ● ●

试起来

-- 依赖 duckdb-luajit(github.com/alitrack/duckdb-luajit)+ 扩展市场装库
SELECT * FROM luajit_module(mode:='install', sql_name:='classifier');

SELECT luajit_s('classifier', {op:'train',
    sql:'SELECT text, label FROM tickets'} OUT r);       -- 训练
SELECT luajit_s('classifier', {op:'predict',
    model_path:'/tmp/model.json',
    texts:['退款到哪里了']} OUT r);                   -- 预测
SELECT luajit_s('classifier', {op:'evaluate',
    model_path:'/tmp/model.json',
    sql:'SELECT text, label FROM test_set'} OUT r);    -- 评估

仓库:

  • 库(Lua 层 + 测试):https://github.com/alitrack/duckdb-luajit-libs
  • 内核(Rust):https://github.com/alitrack/classifier_capi

训练到预测全在本地,数据不出 DuckDB。如果你的场景是几万条文本等着打标签、又不想为每条付 API 钱,这个库就是为那道缝做的。

你手头有没有那种「量大、错误可承受、一直想自动化又没做」的文本分类活?欢迎聊聊它是不是这个方案的菜。