Day23 | 大模型做不好聚类?1.8 万条问题的正确打开方式
苦猿的大模型日记 · 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 给我最大的感动。

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 学进简历
更多推荐
所有评论(0)