从LIKE暴力匹配到LLM智能分类——遗留系统数据分析实战
从LIKE暴力匹配到LLM智能分类——遗留系统数据分析实战
文章目录
- 从LIKE暴力匹配到LLM智能分类——遗留系统数据分析实战
- 一、故事的起点
- 二、LIKE暴力分类:笨但能跑
- 三、包厢利用率:把时间轴展开
- 四、如果现在重做:用LLM做分类
- 4.1 不需要7B,3B就够了
- 4.2 处理边界情况
- 4.3 效率对比
- 五、教训与反思
- 六、如果现在从头做
一、故事的起点
接手一个餐饮老系统的数据分析任务。打开数据库一看,流水表里几万条记录,只有商品名称、数量、单价、金额、日期。没有分类字段,没有品类标签。
老板说:帮我看一下,哪个品类贡献了最多营收?哪个单品卖得最好?
几千个不同的商品名称,人工分类?不现实。当时用的办法,说出来有点笨——用SQL的LIKE逐个品类往里套。
二、LIKE暴力分类:笨但能跑
看看当时写的SQL(保留原样,包括那些注释掉的条件):
insertintocf_flselectdistinct'海鲜类'fl,namesfromcf_product awherenameslike'%虾%'andnamesnotlike'%虾片%'andnamesnotlike'%龙虾味%'就这?对,就这。
每一类都是一轮"试错→排除→再试错"的循环。注释里密密麻麻全是被推翻的条件:
-- 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),两个字段——类别和商品名称。一个品类一个品类地攒出来的。
-- 查某个品类的销售情况selecta.names,count(*)as销售次数,sum(numbers)as销售总量,sum(costs)as总营收,max(price)as最高单价,min(price)as最低单价,round(sum(costs)/sum(numbers),2)as客单价fromcf_product a,cf_fl bwherea.names=b.namesandb.fl='海鲜类'groupbya.namesorderby4desc;跑出来的结果很有意思:同一个菜,最高单价和最低单价能差出好几倍。比如一道"蒜蓉大虾",最高卖过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暴力遍历:
declaren_count number;ctimedate;date1date;hour1 number :=0;begindate1 :=to_date('20220101','yyyymmdd');while(date1<to_date('20220401','yyyymmdd'))loopforrecin(select*frombx)loophour1 :=0;while(hour1<24)loopctime :=date1+hour1/24;selectcount(*)inton_countfrombxlogwheretablename=rec.tablenameandctimebetweenbtimeandetime;ifn_count>0theninsertintobxrq(rq,tablename,chour,kyzt)values(date1,rec.tablename,hour1,'有客');elseinsertintobxrq(rq,tablename,chour,kyzt)values(date1,rec.tablename,hour1,'无客');endif;hour1 :=hour1+1;commit;endloop;endloop;date1 :=date1+1;endloop;end;三个嵌套循环:90天 × 十几间包厢 × 24小时 = 三万多条记录。跑了好几分钟才出结果。
然后用Oracle的PIVOT展开成矩阵:
select*from(selecttablename,rq,chour,decode(kyzt,'无客',1,0)kyfromBXRQ)pivot(sum(ky)forchourin('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表示占用。一张热力图就出来了。
再按星期汇总:
selectdecode(to_char(rq,'d'),'7','星期六','1','星期日','2','星期一','3','星期二','4','星期三','5','星期四','6','星期五')as星期,count(distinctrq)as总天数,sum(casewhenhour22=1andhour23=1andhour0=1then1else0end)as"晚10点后全满天数"frombxkygroupbydecode(to_char(rq,'d'),'7','星期六','1','星期日','2','星期一','3','星期二','4','星期三','5','星期四','6','星期五')orderby3;结论很清晰:周六晚上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甚至更小的模型,本地部署,几行代码搞定:
importjsonfromopenaiimportOpenAI client=OpenAI(base_url="http://localhost:8080/v1",api_key="empty")categories=["海鲜类","牛肉类","猪肉类","禽类","蔬菜类","主食类","酒水类","饮品甜品类","其他"]defclassify_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)returnresp.choices[0].message.content.strip()dishes=["蒜蓉大虾","蚂蚁上树","夫妻肺片","鱼香肉丝","狮子头","青岛啤酒","芒果布丁"]fordindishes:print(f"{d}->{classify_dish(d)}")预期输出:
蒜蓉大虾 -> 海鲜类 蚂蚁上树 -> 猪肉类 夫妻肺片 -> 牛肉类 鱼香肉丝 -> 猪肉类 狮子头 -> 猪肉类 青岛啤酒 -> 酒水类 芒果布丁 -> 饮品甜品类LIKE做不到的事,LLM轻松搞定。"蚂蚁上树"它知道是肉末粉条,不是虫类;"鱼香肉丝"它知道是川菜猪肉,不是鱼类。这是语义理解,不是字面匹配。
4.2 处理边界情况
当然,LLM也不是万能的。遇到本地特色菜、自创菜名,可能也会犯错。比如"九星招牌虾"——如果"九星"是店名,这应该归海鲜类,但LLM可能不确定。
解决方法:少样本提示(Few-shot)。在prompt里给几个例子:
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 + 可视化工具",但分析思路是一样的:先解决分类问题,再做聚合统计,最后回答业务问题。
工具换了,方法论没变。这大概就是数据分析的本质。
