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 四层架构
● ● ●
中间发生了什么:一段真实的训练输出
训练不是黑盒,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,是它的置信下界。数据量够的时候,输出长这样:
真实跑出来就是这副诚实相: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),它抓出四个严重问题,全部修复后才提交:
- 01
NaN 安全的 argmax——权重里混进 NaN 会 panic 穿过 C ABI 边界 - 02
模型文件强校验——坏文件要返回错误 JSON,不能越界崩溃 - 03
cal 分支的 unwrap——异常输入直接崩 - 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 钱,这个库就是为那道缝做的。
你手头有没有那种「量大、错误可承受、一直想自动化又没做」的文本分类活?欢迎聊聊它是不是这个方案的菜。