从LIKE暴力匹配到LLM智能分类------遗留系统数据分析实战
文章目录
- 从LIKE暴力匹配到LLM智能分类------遗留系统数据分析实战
-
- 一、故事的起点
- 二、LIKE暴力分类:笨但能跑
- 三、包厢利用率:把时间轴展开
- 四、如果现在重做:用LLM做分类
-
- [4.1 不需要7B,3B就够了](#4.1 不需要7B,3B就够了)
- [4.2 处理边界情况](#4.2 处理边界情况)
- [4.3 效率对比](#4.3 效率对比)
- 五、教训与反思
- 六、如果现在从头做
一、故事的起点
接手一个餐饮老系统的数据分析任务。打开数据库一看,流水表里几万条记录,只有商品名称、数量、单价、金额、日期。没有分类字段,没有品类标签。
老板说:帮我看一下,哪个品类贡献了最多营收?哪个单品卖得最好?
几千个不同的商品名称,人工分类?不现实。当时用的办法,说出来有点笨------用SQL的LIKE逐个品类往里套。
二、LIKE暴力分类:笨但能跑
看看当时写的SQL(保留原样,包括那些注释掉的条件):
sql
insert into cf_fl
select distinct
'海鲜类' fl,
names
from cf_product a
where names like '%虾%'
and names not like '%虾片%'
and names not like '%龙虾味%'
就这?对,就这。
每一类都是一轮"试错→排除→再试错"的循环。注释里密密麻麻全是被推翻的条件:
sql
-- names like '%蟹%' and names not like '%蟹肉棒%'
-- names like '%鸡%' and names not like '%鸡蛋%'
-- names like '%牛%' and names not like '%牛油%' and names not like '%牛奶%'
为什么这么多排除条件?因为中餐菜名太坑了:
- "蚂蚁上树"------没有蚂蚁,是肉末粉条(猪肉类)
- "夫妻肺片"------没有夫妻,是牛肉牛杂(牛肉类)
- "鱼香肉丝"------没有鱼,是猪肉类
- "狮子头"------没有狮子,是猪肉类
LIKE只能做字面匹配,理解不了语义。每遇到一个误分类,就得加一个排除条件。几千条数据,光分类就调了两三天。
最终效果是这样的:逐条INSERT进分类表(cf_fl),两个字段------类别和商品名称。一个品类一个品类地攒出来的。
sql
-- 查某个品类的销售情况
select a.names,
count(*) as 销售次数,
sum(numbers) as 销售总量,
sum(costs) as 总营收,
max(price) as 最高单价,
min(price) as 最低单价,
round(sum(costs) / sum(numbers), 2) as 客单价
from cf_product a, cf_fl b
where a.names = b.names
and b.fl = '海鲜类'
group by a.names
order by 4 desc;
跑出来的结果很有意思:同一个菜,最高单价和最低单价能差出好几倍。比如一道"蒜蓉大虾",最高卖过128,最低卖过68。这不是折扣,是定价混乱------不同时段、不同服务员可能录入的价格不一样。这种问题,不分析数据根本发现不了。
三、包厢利用率:把时间轴展开
分类问题解决了,接下来是包厢分析。
数据长这样:
| tablename(包厢号) | btime(开台时间) | etime(结账时间) | billid(账单号) |
|---|---|---|---|
| 301 | 2022-01-08 18:30 | 2022-01-08 22:15 | XS-220108-0023 |
| 302 | 2022-01-08 19:00 | 2022-01-09 01:30 | XS-220108-0025 |
老板想知道:每个包厢,每天每个小时,是空闲还是占用?
原始数据只有开台和结账时间,要变成"每小时的状态",得自己算。当时的做法------PL/SQL暴力遍历:
sql
declare
n_count number;
ctime date;
date1 date;
hour1 number := 0;
begin
date1 := to_date('20220101', 'yyyymmdd');
while (date1 < to_date('20220401', 'yyyymmdd')) loop
for rec in (select * from bx) loop
hour1 := 0;
while (hour1 < 24) loop
ctime := date1 + hour1 / 24;
select count(*)
into n_count
from bxlog
where tablename = rec.tablename
and ctime between btime and etime;
if n_count > 0 then
insert into bxrq (rq, tablename, chour, kyzt)
values (date1, rec.tablename, hour1, '有客');
else
insert into bxrq (rq, tablename, chour, kyzt)
values (date1, rec.tablename, hour1, '无客');
end if;
hour1 := hour1 + 1;
commit;
end loop;
end loop;
date1 := date1 + 1;
end loop;
end;
三个嵌套循环:90天 × 十几间包厢 × 24小时 = 三万多条记录。跑了好几分钟才出结果。
然后用Oracle的PIVOT展开成矩阵:
sql
select *
from (select tablename, rq, chour,
decode(kyzt, '无客', 1, 0) ky
from BXRQ)
pivot(sum(ky) for chour in (
'0','1','2','3','4','5','6','7','8','9',
'10','11','12','13','14','15','16','17',
'18','19','20','21','22','23'))
1表示空闲,0表示占用。一张热力图就出来了。
再按星期汇总:
sql
select
decode(to_char(rq, 'd'),
'7','星期六','1','星期日','2','星期一',
'3','星期二','4','星期三','5','星期四','6','星期五') as 星期,
count(distinct rq) as 总天数,
sum(case when hour22=1 and hour23=1 and hour0=1 then 1 else 0 end)
as "晚10点后全满天数"
from bxky
group by decode(to_char(rq, 'd'),
'7','星期六','1','星期日','2','星期一',
'3','星期二','4','星期三','5','星期四','6','星期五')
order by 3;
结论很清晰:周六晚上10点到凌晨,几乎所有包厢都满员。周一到周五的下午,大面积空闲。
这不是什么高深的洞察,但它是真实的。不做数据分析,老板只能凭感觉说"周末忙、平时闲",但到底多忙、多闲,哪个时段最空,不知道。有了数据,决策才有依据------空闲时段推低价套餐引流,高峰时段提高最低消费门槛。
四、如果现在重做:用LLM做分类
时隔几年再看这些SQL,最想改的就是分类那块。
LIKE暴力匹配的痛点很明确:
- 维护成本高。每新增一个菜品,都要手动判断归哪类。新品上架了,分类表没更新,分析就漏了
- 误分类多。"鱼香肉丝"含"鱼"字但不是鱼类,"鸡蛋灌饼"含"鸡"字但不是禽类。排除条件越加越多,越来越难维护
- 无法泛化。换一个餐厅,菜名体系完全不同,所有条件得重写
现在用LLM,事情简单得多。
4.1 不需要7B,3B就够了
菜品分类是短文本分类任务:输入一个菜名(通常2-10个字),输出一个类别标签。这在NLP领域是最基础的任务之一。
| 方案 | 适用场景 | 本任务是否需要 |
|---|---|---|
| BERT/TextCNN | 有标注数据,需训练 | 够用,但要标注 |
| LLM 1.5B-3B | 少样本,零训练 | 最推荐 |
| LLM 7B | 更复杂的推理 | 杀鸡用牛刀 |
| LLM 70B+ | 通用任务 | 完全没必要 |
用Qwen2.5-3B甚至更小的模型,本地部署,几行代码搞定:
python
import json
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="empty")
categories = ["海鲜类", "牛肉类", "猪肉类", "禽类", "蔬菜类", "主食类", "酒水类", "饮品甜品类", "其他"]
def classify_dish(name):
resp = client.chat.completions.create(
model="qwen2.5-3b",
messages=[
{"role": "system", "content": f"你是餐饮菜品分类助手。将菜名归入以下类别之一:{', '.join(categories)}。只输出类别名称,不要解释。"},
{"role": "user", "content": f"菜名:{name}"}
],
temperature=0
)
return resp.choices[0].message.content.strip()
dishes = ["蒜蓉大虾", "蚂蚁上树", "夫妻肺片", "鱼香肉丝", "狮子头", "青岛啤酒", "芒果布丁"]
for d in dishes:
print(f"{d} -> {classify_dish(d)}")
预期输出:
蒜蓉大虾 -> 海鲜类
蚂蚁上树 -> 猪肉类
夫妻肺片 -> 牛肉类
鱼香肉丝 -> 猪肉类
狮子头 -> 猪肉类
青岛啤酒 -> 酒水类
芒果布丁 -> 饮品甜品类
LIKE做不到的事,LLM轻松搞定。"蚂蚁上树"它知道是肉末粉条,不是虫类;"鱼香肉丝"它知道是川菜猪肉,不是鱼类。这是语义理解,不是字面匹配。
4.2 处理边界情况
当然,LLM也不是万能的。遇到本地特色菜、自创菜名,可能也会犯错。比如"九星招牌虾"------如果"九星"是店名,这应该归海鲜类,但LLM可能不确定。
解决方法:少样本提示(Few-shot)。在prompt里给几个例子:
python
messages = [
{"role": "system", "content": f"你是餐饮菜品分类助手。将菜名归入以下类别之一:{', '.join(categories)}。只输出类别名称。\n\n示例:\n九星招牌虾 -> 海鲜类\n秘制烤羊排 -> 牛羊肉类\n特色素三鲜 -> 蔬菜类"},
{"role": "user", "content": f"菜名:{name}"}
]
加上几个本店特色菜的例子,准确率能从85%拉到95%以上。剩下5%的异常值,人工过一遍就行。
4.3 效率对比
| 维度 | LIKE暴力匹配 | LLM分类 |
|---|---|---|
| 开发时间 | 2-3天(反复调条件) | 半天(写prompt+跑批) |
| 新增菜品 | 手动加条件 | 自动分类 |
| 误分类率 | 10%-15%(语义陷阱) | < 5%(加few-shot后) |
| 可迁移性 | 换店重写 | 改几个few-shot例子 |
| 维护成本 | 持续累积排除条件 | 基本无需维护 |
几千条数据,本地3B模型跑完不到半小时。加上人工抽查,整个过程半天搞定。当年用LIKE,光"虾"这个关键字就调了半天。
五、教训与反思
回头看这次数据分析,最大的教训不是技术选型,而是:遗留系统的数据质量,决定了分析的上限。
- 菜名不统一:同一个菜,有的写"蒜蓉虾",有的写"蒜蓉大虾",有的写"蒜蓉开背虾"。LIKE和LLM都会把它们当成不同的菜
- 价格混乱:同菜不同价,可能是时段定价,也可能是录错了
- 包厢数据有脏数据:有些记录的开台时间晚于结账时间,PL/SQL遍历时会漏算
这些问题不是SQL或LLM能解决的,需要在数据采集端就做好规范化。
第二个体会是:数据分析的价值不在于技术多酷,而在于能不能回答业务问题。
包厢分析那段PL/SQL,写法很丑------三层嵌套循环,逐行INSERT,跑好几分钟。但跑出来的那张热力图,直接告诉老板"周六晚10点以后一房难求,周三下午全空"。这个结论值多少钱?如果据此调整定价策略,一个月多出来的营收可能覆盖整个数据分析的成本。
LIKE分类也很丑。但当年没有更好的工具,它解决了问题。现在有了LLM,同样的任务可以用更优雅的方式做。技术的进步就在于此:不是让不可能变成可能,而是让可能的成本从三天降到半天。
六、如果现在从头做
我会这样设计:
- 分类:LLM(3B本地模型),自动分类 + 人工抽查异常值
- 包厢分析:不再用PL/SQL暴力遍历,改用SQL窗口函数或Python pandas处理时间区间
- 可视化:包厢热力图直接用Python(matplotlib/seaborn),不用在数据库里PIVOT
- 数据清洗:先用LLM做菜名标准化("蒜蓉虾"和"蒜蓉大虾"合并为同一道菜),再分类
技术栈从"Oracle + 纯SQL"变成"Python + LLM + 可视化工具",但分析思路是一样的:先解决分类问题,再做聚合统计,最后回答业务问题。
工具换了,方法论没变。这大概就是数据分析的本质。