一、背景
最近,我们需要开发一个面向B端的Agent答疑系统,在做用户问题识别的时候,我们面临这样一个问题:
用户每次提问的表述千差万别,但他们想解决的问题往往可以归纳为有限的几类。
举个例子:
| 用户原始提问 | 语义可归属为 |
|---|---|
| "怎么申请退款?" | → 退款流程 |
| "我不想要了,钱能退回来吗?" | → 退款流程 |
| "订单取消后多久到账?" | → 退款流程 |
| "东西坏了怎么换新的?" | → 售后换修 |
| "收到商品有质量问题" | → 售后换修 |
| "保修期是多长时间?" | → 售后换修 |
| "发货大概要几天?" | → 物流查询 |
| "我的包裹到哪了?" | → 物流查询 |
| "为什么还没收到货?" | → 物流查询 |
可以看到,表面上:8 个问题的表述完全不同,没有一个重复的,本质上:它们只属于 3 个话题类别(退款、售后、物流)
如果系统能够自动完成这种 「用户提问 → 语义簇」的映射,就能带来巨大的效率提升,例如:
- 智能路由:用户问"钱能退回来吗",Agent 直接识别出属于「退款流程」簇,无需多轮追问即可调用对应的处理能力
- 批量处理:同一簇内的问题可以复用相同的解决方案或模板,减少重复劳动
- 冷启动知识库构建:从历史对话中自动聚类出高频问题簇,快速沉淀为 FAQ 或标准问答对
- 新问题发现:当用户提问无法匹配任何已有簇时,说明遇到了新类型的问题,可触发人工介入或创建新的处理流程
而要实现这个流程,就需要用到层次聚类。
二、什么是层次聚类
层次聚类(Hierarchical Clustering) 是一种通过构建「簇的层级树状结构」来对数据进行分组的方法。它不要求预先指定聚类的数量,而是生成一棵 树状图(Dendrogram),可以在不同层级上进行切割,得到不同粒度的分组结果
简单说来,就是先把历史文本"洗切干净"转成数字指纹,再按相似度逐层合并成若干语义分组并打上关键词标签;之后新问题来了只需跟各组的"代言人"打个分——够像就归组直接走对应处理流程
与其他文本聚类方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| K-Means | 速度快,实现简单 | 需预先指定 K 值;对初始中心敏感;假设簇为凸形 | 簇数已知且分布均匀 |
| DBSCAN | 无需指定簇数;可识别噪声 | 对高维数据效果差;参数敏感 | 空间数据、密度不均 |
| 层次聚类 | 无需预设簇数;生成树状结构;结果可解释性强 | 时间复杂度较高 O(n²) | 中小规模文本集;需要理解层级关系 |
| LDA 主题模型 | 可解释性好;输出主题-词分布 | 需要预设主题数;计算开销大 | 长文本主题发现 |
选择层次聚类的理由
- 无需预先指定聚类数目:文本数据的真实分组数量通常是未知的,层次聚类可以通过树状图(Dendrogram)在不同粒度上切割
- 结果具有层级结构:可以直观地看到哪些文本"更接近",哪些分组之间的距离更远
- 对文本数据友好:配合 TF-IDF 向量化和余弦相似度,能有效捕捉文本语义
- Ward 连接方式:最小化簇内方差,倾向于生成大小相近的紧凑簇
三、方案设计
3.1 整体架构
整个系统由以下几个核心模块组成:
┌─────────────────────────────────────────────────────────┐
│ 文本聚类系统 │
├─────────────┬─────────────┬─────────────┬───────────────┤
│ 文本预处理 │ 向量化 │ 层次聚类 │ 增量预测 │
│ │ │ │ │
│ · 清洗 │ · TF-IDF │ · Ward连接 │ · 相似度匹配 │
│ · 分词 │ 特征提取 │ · 凝聚合并 │ · 阈值判断 │
│ · 停用词过滤 │ │ · UUID标识 │ · 聚类中心更新│
└─────────────┴─────────────┴─────────────┴───────────────┘
│
┌────────┴────────┐
│ 辅助能力 │
├─────────────────┤
│ · 关键词提取 │
│ · 聚类信息查询 │
│ · 结果可视化 │
└─────────────────┘
3.2 核心流程
第一阶段:初始聚类(fit)
原始文本 → 预处理 → TF-IDF向量化 → 层次聚类 → UUID标识 → 计算聚类中心 → 提取关键词
步骤详解:
-
文本预处理
- 去除特殊字符和标点符号
- 使用分词工具(如 jieba)进行中文分词
- 过滤停用词和无意义短词
-
TF-IDF 向量化
- 将预处理后的文本转换为 TF-IDF 特征向量
- TF-IDF 能有效反映词语在文档中的重要性,降低高频通用词的权重
-
层次聚类
- 使用
AgglomerativeClustering,采用 Ward 连接方式 - Ward 方法通过最小化簇内方差(within-cluster variance)来合并簇,生成的簇更加紧凑
- 输入参数
n_clusters控制最终切分的簇数量
- 使用
-
UUID 标识机制
- 聚类算法输出的数字索引(0, 1, 2...)被映射为全局唯一的 UUID
- 这样做的好处是:避免数字索引的歧义性,支持分布式场景下的唯一标识
-
聚类中心计算
- 对每个簇内的所有 TF-IDF 向量取均值,得到该簇的中心向量
- 中心向量代表了该簇的"典型语义方向"
-
关键词提取
- 使用 TextRank 算法从每个簇的聚合文本中提取 Top-K 关键词
- 关键词用于直观地理解和标注每个聚类的语义主题
第二阶段:增量预测(predict)
当有新文本到来时,系统不需要重新对所有文本进行聚类,而是采用增量方式:
新文本 → 预处理 → TF-IDF向量化 → 与各聚类中心计算余弦相似度
│
┌─────────┴──────────┐
│ max_sim ≥ 阈值? │
├─────────┬──────────┤
│ Yes │ No │
│ 归入已有簇 │ 创建新簇 │
│ 更新中心 │ 分配UUID │
└─────────┴──────────┘
关键设计:
- 余弦相似度作为度量:衡量新文本向量与各聚类中心的语义接近程度
- 阈值控制:当最大相似度低于设定阈值(如 0.3)时,认为新文本不属于任何现有簇,自动创建新簇
- 在线更新聚类中心:当新文本归入某簇后,用滑动平均的方式更新该簇的中心向量,使中心逐步"漂移"以适应新数据
四、核心代码解析
4.1 数据结构与初始化
class TextClusteringSystem:
def __init__(self, stopwords_path=None, n_clusters=5):
self.stopwords = self._load_stopwords(stopwords_path)
self.vectorizer = TfidfVectorizer() # TF-IDF 向量化器
self.cluster_model = AgglomerativeClustering(
n_clusters=n_clusters, linkage="ward" # Ward 连接的层次聚类
)
self.clusters = None # 每个文本所属的聚类 UUID
self.cluster_centers = None # 各聚类的中心向量
self.cluster_index_to_uuid = {} # 数字索引 → UUID 映射
self.cluster_uuid_to_index = {} # UUID → 数字索引 映射
self.corpus = [] # 预处理后的语料库
self.tfidf_matrix = None # TF-IDF 矩阵
self.keywords_per_cluster = None # 各聚类的关键词
4.2 文本预处理管道
def _preprocess_text(self, text):
# 第一步:去除标点和特殊字符
text = re.sub(f"[{re.escape(string.punctuation)}]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
# 第二步:中文分词
words = jieba.cut(text)
# 第三步:停用词过滤 + 长度过滤
words = [word for word in words
if word not in self.stopwords and len(word) > 1]
return " ".join(words)
预处理是整个 pipeline 的基础。对于中文文本而言,分词质量直接影响聚类效果。实际应用中可根据领域补充自定义词典。
4.3 初始聚类核心逻辑
def fit(self, texts):
# 1. 批量预处理
self.corpus = [self._preprocess_text(text) for text in texts]
# 2. TF-IDF 向量化(fit_transform 学习词汇表并转换)
self.tfidf_matrix = self.vectorizer.fit_transform(self.corpus)
# 3. 层次聚类,得到每个样本的数字簇标签
numeric_clusters = self.cluster_model.fit_predict(
self.tfidf_matrix.toarray()
)
# 4. 数字标签 → UUID 映射(保证全局唯一性)
for numeric_id in sorted(set(numeric_clusters)):
cluster_uuid = str(uuid.uuid4())
self.cluster_index_to_uuid[numeric_id] = cluster_uuid
self.cluster_uuid_to_index[cluster_uuid] = numeric_id
self.clusters = [
self.cluster_index_to_uuid[nid] for nid in numeric_clusters
]
# 5. 后续处理:计算中心 + 提取关键词
self._calculate_cluster_centers()
self._extract_keywords_per_cluster()
return self.clusters
这里有一个重要的设计决策:使用 UUID 替代数字索引作为聚类标识符。这避免了不同批次聚类结果之间数字索引冲突的问题,也便于在持久化和分布式环境中使用。
4.4 增量预测的核心逻辑
def predict(self, new_texts, threshold=0.3):
processed_texts = [self._preprocess_text(t) for t in new_texts]
new_tfidf = self.vectorizer.transform(processed_texts) # 注意:用 transform,不是 fit_transform
new_clusters = []
sorted_uuids = sorted(set(self.clusters), key=...)
for _, tfidf in enumerate(new_tfidf.toarray()):
# 计算与所有现有聚类中心的余弦相似度
similarities = [
cosine_similarity([tfidf], [center])[0][0]
for center in self.cluster_centers
]
max_sim = max(similarities)
best_idx = similarities.index(max_sim)
if max_sim >= threshold:
# 归入已有簇,并在线更新中心
new_clusters.append(sorted_uuids[best_idx])
self.cluster_centers[best_idx] = np.mean(
[self.cluster_centers[best_idx], tfidf], axis=0
)
else:
# 创建全新簇
new_uuid = str(uuid.uuid4())
new_clusters.append(new_uuid)
self.cluster_centers.append(tfidf)
# ... 更新映射关系 ...
# 同步更新语料库和聚类列表
self.corpus.extend(processed_texts)
self.clusters.extend(new_clusters)
self._extract_keywords_per_cluster() # 刷新关键词
return new_clusters
增量预测的关键点:
- 使用
transform而非fit_transform:新文本必须使用初始聚类时学习到的同一份词汇表,否则向量空间不一致 - 阈值的选择:阈值越低,新文本越容易被归入已有簇(可能引入噪声);阈值越高,越容易创建新簇(可能导致碎片化)。0.3 是一个经验起点,需根据实际数据调优
- 聚类中心的在线更新:采用简单均值更新的方式,类似于在线学习中的移动平均思想
4.5 聚类中心计算与关键词提取
def _calculate_cluster_centers(self):
"""对每个簇内的所有向量取均值"""
for cluster_uuid in sorted_uuids:
mask = [uuid == cluster_uuid for uuid in self.clusters]
cluster_vectors = self.tfidf_matrix.toarray()[mask]
center = np.mean(cluster_vectors, axis=0)
self.cluster_centers.append(center)
def _extract_keywords_per_cluster(self, top_n=5):
"""使用 TextRank 提取每个簇的代表关键词"""
for cluster_uuid in set(self.clusters):
# 聚合该簇所有文本
cluster_text = " ".join(
self.corpus[i] for i in range(len(self.corpus))
if self.clusters[i] == cluster_uuid
)
keywords = jieba.analyse.textrank(cluster_text, topK=top_n)
self.keywords_per_cluster[cluster_uuid] = keywords
五、关键技术细节
5.1 TF-IDF + 余弦相似度的组合
TF-IDF 将文本转换为稀疏的高维向量,其中:
- TF(词频):反映词在当前文档中的重要性
- IDF(逆文档频率):降低在所有文档中都常见的词的权重
配合 余弦相似度 度量两个向量之间的夹角余弦值,取值范围 [-1, 1],值越大表示语义越接近。这种组合在文本场景中被广泛验证有效。
5.2 Ward 连接方式的数学直觉
Ward 连接在每次合并两个簇时,选择使 合并后簇内方差增量最小 的那对簇。公式如下:
其中 、 为两簇的样本数,、 为两簇的质心。这意味着 Ward 倾向于合并规模相近且距离较近的簇,产生的结果通常比单链接(single linkage)或全链接(complete linkage)更加均衡。
5.3 UUID 标识体系的设计考量
聚类算法输出: [0, 0, 1, 1, 1, 2, 2, ...] (数字索引)
↓ 映射
系统内部使用: ["a1b2c3...", "a1b2c3...", "d4e5f6...", "d4e5f6...", ...] (UUID)
这样做的好处:
- 全局唯一:不会出现两次聚类都从 0 开始编号导致的混淆
- 可序列化:UUID 是字符串,便于 JSON 序列化和数据库存储
- 无信息泄露:外部无法从 UUID 推断出聚类的数量或顺序
六、效果与应用
6.1 典型输出示例
经过聚类后,系统可以输出类似以下的分组信息:
| 聚类 ID | 文本数量 | 代表关键词 | 语义解读 |
|---|---|---|---|
| 簇 A | 5 | 信用卡, 申请, 额度, 还款, 账单 | 信用卡相关咨询 |
| 簇 B | 3 | 手机银行, 转账, 开通, 安全 | 移动端银行服务 |
| 簇 C | 4 | 理财, 基金, 股票, 产品, 风险 | 投资理财相关 |
| 簇 D (新增) | 2 | 西红柿, 鸡蛋, 吃, 晚上 | (新发现的独立话题) |
6.2 新用户提问的完整执行流程
聚类系统完成初始分组后,当新用户再次发起提问时,整个系统会按照以下流程运转:
┌─────────────────────────────────────────────────────────────────────┐
│ 用户发起提问 │
│ "信用卡丢了怎么补办?" │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 1:文本预处理 │
│ ───────────────── │
│ 原始文本: "信用卡丢了怎么补办?" │
│ ↓ 去除标点、分词、停用词过滤 │
│ 处理结果: "信用卡 丢失 补办" │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 2:TF-IDF 向量化(复用已有词汇表) │
│ ─────────────────────────────────────── │
│ 使用初始聚类时学习到的同一份词汇表进行 transform │
│ → 得到与所有历史文本在同一向量空间中的稀疏特征向量 │
│ → 向量维度 = 词汇表大小(可能数千~数万维) │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 3:与各聚类中心计算余弦相似度 │
│ ─────────────────────────────────── │
│ │
│ 新文本向量 · │
│ ╲ │
│ ╲ sim=0.82 ──→ 簇A中心 [信用卡,申请,额度,还款,账单] │
│ ╲ │
│ ╲ sim=0.31 ──→ 簇B中心 [手机银行,转账,开通,安全] │
│ ╲ │
│ ╲ sim=0.18 ──→ 簇C中心 [理财,基金,股票,产品,风险] │
│ ╲ │
│ ╲ sim=0.05 ──→ 簇D中心 [西红柿,鸡蛋,吃,晚上] │
│ │
│ → 最大相似度 = 0.82(簇 A) │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────┐
│ max_sim ≥ 阈值(0.3)? │
└──────────┬───────────┘
│
┌────────────────┴────────────────┐
│ ✅ Yes:0.82 ≥ 0.3 │
│ → 归入已有簇 A(信用卡相关) │
└────────────────┬────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 4:执行后续动作 │
│ ───────────────── │
│ ① 返回聚类结果:该问题属于「簇 A」 │
│ ② 在线更新簇 A 的聚类中心(滑动平均) │
│ new_center = mean(old_center, new_vector) │
│ ③ 刷新簇 A 的关键词列表 │
│ ④ 将新文本追加到语料库中 │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 5:下游业务消费 │
│ ───────────────── │
│ Agent 获取到「簇 A」标识后: │
│ · 查询簇 A 对应的处理方案 / FAQ模板 / 自动化工具 │
│ · 直接执行对应操作 或 返回标准化答案 │
│ · 无需多轮追问,实现"一句话直达" │
└─────────────────────────────────────────────────────────────────────┘
另一种情况:遇到全新问题时
如果用户问的是 "今天天气怎么样?":
Step 1~2: 同上(预处理 + 向量化)
Step 3: 计算相似度
→ 与簇A(信用卡): sim=0.02
→ 与簇B(手机银行): sim=0.01
→ 与簇C(理财): sim=0.03
→ 与簇D(西红柿): sim=0.08
→ 最大相似度 = 0.08
判断: max_sim(0.08) < 阈值(0.3)
→ ❌ 不属于任何现有簇!
动作:
① 创建全新的簇 E,分配新的 UUID
② 将该文本作为簇 E 的第一个成员和初始中心
③ 触发"新问题发现"通知 → 可转人工处理或沉淀为新类型
6.3 局限性与改进方向
| 局限 | 说明 | 改进思路 |
|---|---|---|
| TF-IDF 忽略语义 | 基于 词面匹配,同义词无法识别 | 使用预训练 Embedding(如 sentence-transformers)替代 TF-IDF |
| 层次聚类 O(n²) 复杂度 | 大规模数据(万级以上)较慢 | 先用近似方法(如 MinHash LSH)粗筛,再对候选集做精确聚类 |
| 阈值需人工设定 | 不同数据分布的最优阈值不同 | 引入自适应阈值或基于统计的方法自动确定 |
| 聚类中心漂移 | 在线更新可能导致中心偏移 | 设定冻结条件或定期重新聚类校正 |
七、总结
本文分享了一个通过 TF-IDF 向量化 + Ward 层次聚类 + 增量预测 的组合,构建了一套完整的文本自动分组系统的方案,如果你有类似的需求,欢迎在评论区一起交流。