苦猿的大模型日记 · Day23 · 1.8万条问题的聚类实战-帮普通人把AI学进简历系列

前言:那个让我盯了很久的 16.51%

1.8 万条用户问题摆在我面前,业务方一句话:找出高频痛点。

我扫了一眼那张 Excel,第一反应特别"AI 时代"——丢给大模型,让它自己聚类。

然后我算了一笔账,冷静了。

把 1.8 万条短句全塞进 prompt,任何一个模型的上下文窗口都吃不消;就算硬塞进去,光是这一次聚类烧的 token 钱,够我吃好几顿火锅;更别提大模型分出来的类,同一个输入跑两遍,结果都不一样。

我没硬刚。

老老实实搭了条 embedding + UMAP + HDBSCAN 的管线,把"聚类"这件事还给数学,只让大模型干它最擅长的那一丢丢活儿。

跑完那天晚上,我盯着排行榜第一名看了好久。

16.51%,94 句话被问了 3577 次,全是同一个意思——

"转人工。"

海量数据找高频痛点示意图


PART 01:为什么大模型做不好聚类

先说结论,免得你以为我在黑大模型。

大模型理解单条句子的语义,是顶级的。但让它对一万条数据做聚类,它有三宗罪。

第一,上下文爆炸。

1.8 万条短句,每条平均 20 个字,全塞进 prompt 是三十多万字。现在最强的模型也就 100 万上下文,塞是塞得下,但你基本没预算这么干。

第二,成本。

每一次聚类调用都要把全量数据重新喂一遍。调一次参数跑一次,钱如流水。

第三,也是最致命的——一致性差。

聚类本质是个几何问题:在一堆向量点里,把挨得近的归成一堆。

大模型是个语言模型,它擅长理解"这句话在说什么",但不擅长在一个超大的点集上维持稳定的、可复现的几何划分。

你今天给它一万条,它分成 60 类;明天同样一万条,它分成 73 类。哪个对?它自己都不知道。

聚类是几何问题,不是语言问题。

让一个语言天才去做几何题,难怪翻车。

聚类是几何问题示意图


PART 02:正确管线的全貌

想明白这点之后,我搭了条五步走的管线。

一句话概括它的灵魂:

让几何结构去做分组决策,让大模型只负责起名。

embedding 负责把"语义"变成"向量位置",UMAP 和 HDBSCAN 负责在向量空间里把点分堆——这一段全靠数学,便宜、稳定、跑十遍结果都一样。

大模型只在最后登场,给它一个已经分好的堆,让它给这堆起个名字、写个概括。它处理的不是一万条,而是「簇数 × 15 条」——几十个堆、每堆 15 句代表,几百条而已。

这就是分工。

伪代码长这样:

# 一句话管线:几何做分组,LLM 做命名
counter = load_questions("合并分类结果.xlsx")          # 读 + 去重计数
embeddings = embed_texts(counter.keys(), cache=True)   # ① 向量化
low = umap_reduce(embeddings, dim=10)                  # ② 降维
labels = hdbscan_cluster(low, min_cluster_size=50)     # ③ 聚类
reps = top_reps_per_cluster(texts, embeddings, labels) # ④ 每簇取 15 句代表
names = [llm_name(r) for r in reps]                    # ⑤ LLM 给每堆起名

下面三节,我们拆开看 ①②③ 这三步里,分别踩了多少算法选择的坑。

文本聚类管线流程图


PART 03:维度灾难,和 HDBSCAN 为什么碾压 KMeans

先说 embedding:能复用就别自己训

我没有自己训向量模型,直接复用了线上检索用的 text-embedding-v3

理由很实在——聚类看到的语义结构,要和用户真实检索时一致。你用 A 模型聚的类,拿 B 模型去检索,对不上号。

每句话变成一个 1024 维的向量,距离用 cosine(只看方向,不被句长带偏,特别适合短文本)。

然后,你必须降维——这是大多数人会漏的一步

很多人拿到 1024 维的向量,直接喂给聚类算法。

结果必然是一塌糊涂。

原因叫维度灾难:维度一高,空间里任意两个点之间的距离都趋于相等。

你想想,密度聚类的核心是"找点扎堆的地方",可一旦所有点之间的距离都差不多,"扎堆"这个概念本身就失效了——每个点看起来都一样远,你怎么找密集区?

所以必须降维。从 1024 维降到 10 维。

为什么用 UMAP 而不是 PCA?

PCA 是线性的,它只保留方差最大的几个方向。可语义这玩意儿是个非线性流形——"我要转人工"和"找客服"在向量空间里不是直线靠近的,是被某种弯曲结构叠在一起的。PCA 这种直线思维会丢掉大量结构。

UMAP 是流形学习:它先在高维空间用 k 近邻建一张图,记住"谁和谁挨着",再在低维空间里重建一张尽量同构的图。局部细节和全局结构都能保住。

HDBSCAN:这一步是整条管线的灵魂

聚类算法我选了 HDBSCAN,没用很多人默认的 KMeans。

KMeans 有三个硬伤:

第一,它逼你先告诉它"分几类"(K)。可你根本不知道用户的问题该分成 60 类还是 80 类——这正是你想让算法告诉你的事。

第二,它假设每个簇都是球形的、大小差不多。但用户问题的分布是极度不均衡的——一个高频主题可能有几百句,一个冷门的只有三十句。KMeans 会把大簇切烂、把小簇撑大。

第三,它会把每一个点都强行塞进某个类。可现实是,总有那么一批奇奇怪怪的长尾问题,它们谁也不挨着,硬塞只会污染别的簇。

HDBSCAN 全部解决:

它不需要你预设 K——簇数是数据自己"长"出来的。

它基于密度——核心是"互达距离"和一棵层次树,能发现任意形状的簇,不要求球形。

它最大的优点是自动识别噪声:密度不够、归不进任何簇的点,直接打上 label=-1,老老实实承认"这个我不知道归哪",而不是瞎塞。

import umap
import hdbscan

# 1024 维直接聚类会撞维度灾难,先降到 10 维
low = umap.UMAP(
    n_neighbors=15, n_components=10,
    metric="cosine", random_state=42,
).fit_transform(embeddings)

# 不预设 K,密度自适应;label=-1 是噪声(长尾问题)
labels = hdbscan.HDBSCAN(
    min_cluster_size=50,        # 一个簇最少 50 句,控制粒度
    min_samples=5,
    metric="euclidean",
    cluster_selection_method="eom",   # 从层次树里挑"质量最大"的簇
).fit_predict(low)

min_cluster_size 是你唯一需要调的旋钮——调大出粗主题,调小出细主题。

让数据自己说话,该多少类就多少类。

这是 HDBSCAN 给我最大的感动。

HDBSCAN聚类示意图


PART 04:实战踩坑与最炸的发现

算法讲完,真正的实战才刚开始。这一节全是用钱和时间换来的教训。

坑一:1.8 万行里,有 8000 行是空的

第一版跑完,我把结果发给业务。

业务回我一句灵魂拷问:"为什么统计的是 1 万条,不是 1.8 万?"

我一看数据,8008 行的"改写后问题"那一列是空的。

原因不复杂:这些是非检索类轮次(闲聊、系统打招呼),后端压根没生成改写结果。空行自然聚不了类。

解法很朴素——改写为空,就用原问题兜底

# 改写后问题为空时,用原问题列兜底
mask_empty = df["改写后问题"].isna() | (df["改写后问题"].str.strip() == "")
df["改写后问题"] = df["改写后问题"].where(~mask_empty, df["问题"])

改完一跑,18600 行全覆盖,零丢失

这件事提醒我:算法再漂亮,数据对不上账,业务一句"为什么不是 1.8 万"就能把你问住。

坑二:粒度太碎,103 个类没人看得过来

第一次用 min_cluster_size=30,聚出 103 个簇

太多了。光"券核销"就拆成了"抖音券核销""优惠券核销""券核销记录查询"三堆,业务根本看不过来。

调到 50,103 合并成 69,舒服了。

min_cluster_size 这个参数,调一次就是重跑一次——这就引出下面这个最值钱的工程技巧。

坑三:排序 bug,差点让真正的高频埋没

这是个我自己挖的坑。

一开始我按"去重句数"给主题排序。结果第一名是个具体产品问题,"门店 IT 客服"——那个真正被问了 895 次的——被压到了第三名。

为什么?因为"去重句数"衡量的是多样性(有多少种不同说法),不是频次(一共被问了多少次)。

用户要的是"高频",应该是总频次。

# 按总频次(含重复)降序,这才是"高频"
df = df.sort_values("总频次", ascending=False).reset_index(drop=True)
df["排名"] = df.index + 1

改完排序,第一名瞬间变成——

"人工客服联系",895 次。

工程技巧:两个缓存,让调参重跑几乎不花钱

整条管线里最贵的是两件事:1.2 万条句子的 embedding,和几十个簇的 LLM 命名。

我的做法是全部缓存

embedding 按文本内容的 hash 存成 .npy

def embed_texts(texts, api_key, cache_path, batch_size=10):
    if cache_path.exists():           # 缓存命中,秒级返回,不花钱
        return np.load(cache_path)
    all_vec = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        resp = requests.post(EMBED_URL, json={
            "model": "text-embedding-v3",
            "input": {"texts": batch},
            "parameters": {"dimension": 1024, "output_type": "dense"},
        }, headers={"Authorization": f"Bearer {api_key}"})
        for e in resp.json()["output"]["embeddings"]:
            all_vec.append(e["embedding"])
        time.sleep(0.1)               # 配合 DashScope 的 TPM 限流
    arr = np.asarray(all_vec, dtype=np.float32)
    np.save(cache_path, arr)          # 持久化
    return arr

LLM 命名结果也按"代表句内容"的 hash 缓存成 JSON。

效果是——第一次跑全量,embedding 烧了十几分钟、几毛钱;之后调 min_cluster_size 重跑,embedding 命中缓存、聚类几秒、只有簇结构变了的那些堆才重新问 LLM。

几十秒出结果,一分钱不花。

这是离线脚本该有的样子。

最炸的发现:25% 的人都在问转人工

所有坑踩完,最后那张榜单,看得我有点沉默。

排名 主题 频次 占比
#1 人工客服联系 3577 16.51%
#2 转人工服务请求 892 4.12%
#5 IT 客服联系方式 625 2.88%
#8 门店 IT 客服联系 425 1.96%

和"转人工 / 找客服"相关的 4 个簇,加起来占 25%

第一名尤其震撼——94 句不同的说法,被问了 3577 次。用户翻来覆去就是一句话:我要找人工。

这是个值得专项治理的信号。而上一版(只聚改写后问题)完全没看出来——因为大量用户用的是最朴素的口语"转人工",这些话根本没进改写流程。

兜底那一下,把真相救了回来。

数据闭环最终长这样,一分不差:

原始 18600 行
   → 兜底后全覆盖
   → 拆分多问题 21670 句
   → 去重 12722 句
   → 69 个主题簇 + 噪声长尾

高频问题排行榜示意图


结尾:把聚类交给几何,把命名交给语言

这次实战给我最大的启发,不是 HDBSCAN 多好用,而是分工

大模型很强,但它不是万能的扳手。遇到一个活儿,先想清楚:哪部分是几何问题,哪部分是语言问题。

聚类这种全局的、要稳定可复现的几何决策,交给 embedding 和数学算法——便宜、靠谱。

LLM 只在它最擅长的地方登场:理解一小撮样本,给它起个贴切的名字、写句人话概括。

把聚类交给几何,把命名交给语言——这是大模型时代分工的真相。

工具会过时,今天用 HDBSCAN,明天可能换别的。但"让数学做决策、让语言做表达"这个分工,我觉得能管很久。

互动时间:你在工作里有没有那种"第一反应想全丢给大模型"的活儿?最后是真香了,还是翻车了?评论区聊聊,我挨个看。

下一篇,我们聊聊 RAG 检索里那个总被忽视的环节——召回之后,怎么让大模型别瞎编。关注「苦猿」,别走丢。

— END —

苦猿 · 帮普通人把 AI 学进简历

更多推荐