聊一下知识答疑Agent的“层次聚类”流程

一、背景

最近,我们需要开发一个面向B端的Agent答疑系统,在做用户问题识别的时候,我们面临这样一个问题:

用户每次提问的表述千差万别,但他们想解决的问题往往可以归纳为有限的几类。

举个例子:

用户原始提问 语义可归属为
"怎么申请退款?" 退款流程
"我不想要了,钱能退回来吗?" 退款流程
"订单取消后多久到账?" 退款流程
"东西坏了怎么换新的?" 售后换修
"收到商品有质量问题" 售后换修
"保修期是多长时间?" 售后换修
"发货大概要几天?" 物流查询
"我的包裹到哪了?" 物流查询
"为什么还没收到货?" 物流查询

可以看到,表面上 :8 个问题的表述完全不同,没有一个重复的,本质上:它们只属于 3 个话题类别(退款、售后、物流)

如果系统能够自动完成这种 「用户提问 → 语义簇」的映射,就能带来巨大的效率提升,例如:

  • 智能路由:用户问"钱能退回来吗",Agent 直接识别出属于「退款流程」簇,无需多轮追问即可调用对应的处理能力
  • 批量处理:同一簇内的问题可以复用相同的解决方案或模板,减少重复劳动
  • 冷启动知识库构建:从历史对话中自动聚类出高频问题簇,快速沉淀为 FAQ 或标准问答对
  • 新问题发现:当用户提问无法匹配任何已有簇时,说明遇到了新类型的问题,可触发人工介入或创建新的处理流程

而要实现这个流程,就需要用到层次聚类


二、什么是层次聚类

层次聚类(Hierarchical Clustering) 是一种通过构建「簇的层级树状结构」来对数据进行分组的方法。它不要求预先指定聚类的数量,而是生成一棵 树状图(Dendrogram),可以在不同层级上进行切割,得到不同粒度的分组结果

简单说来,就是先把历史文本"洗切干净"转成数字指纹,再按相似度逐层合并成若干语义分组并打上关键词标签;之后新问题来了只需跟各组的"代言人"打个分------够像就归组直接走对应处理流程

与其他文本聚类方法对比

方法 优点 缺点 适用场景
K-Means 速度快,实现简单 需预先指定 K 值;对初始中心敏感;假设簇为凸形 簇数已知且分布均匀
DBSCAN 无需指定簇数;可识别噪声 对高维数据效果差;参数敏感 空间数据、密度不均
层次聚类 无需预设簇数;生成树状结构;结果可解释性强 时间复杂度较高 O(n²) 中小规模文本集;需要理解层级关系
LDA 主题模型 可解释性好;输出主题-词分布 需要预设主题数;计算开销大 长文本主题发现

选择层次聚类的理由

  1. 无需预先指定聚类数目:文本数据的真实分组数量通常是未知的,层次聚类可以通过树状图(Dendrogram)在不同粒度上切割
  2. 结果具有层级结构:可以直观地看到哪些文本"更接近",哪些分组之间的距离更远
  3. 对文本数据友好:配合 TF-IDF 向量化和余弦相似度,能有效捕捉文本语义
  4. Ward 连接方式:最小化簇内方差,倾向于生成大小相近的紧凑簇

三、方案设计

3.1 整体架构

整个系统由以下几个核心模块组成:

markdown 复制代码
┌─────────────────────────────────────────────────────────┐
│                    文本聚类系统                           │
├─────────────┬─────────────┬─────────────┬───────────────┤
│  文本预处理  │   向量化    │  层次聚类   │  增量预测     │
│             │             │             │               │
│ · 清洗      │ · TF-IDF   │ · Ward连接  │ · 相似度匹配  │
│ · 分词      │   特征提取  │ · 凝聚合并  │ · 阈值判断    │
│ · 停用词过滤 │             │ · UUID标识  │ · 聚类中心更新│
└─────────────┴─────────────┴─────────────┴───────────────┘
                              │
                     ┌────────┴────────┐
                     │   辅助能力       │
                     ├─────────────────┤
                     │ · 关键词提取     │
                     │ · 聚类信息查询   │
                     │ · 结果可视化     │
                     └─────────────────┘

3.2 核心流程

第一阶段:初始聚类(fit)

复制代码
原始文本 → 预处理 → TF-IDF向量化 → 层次聚类 → UUID标识 → 计算聚类中心 → 提取关键词

步骤详解:

  1. 文本预处理

    • 去除特殊字符和标点符号
    • 使用分词工具(如 jieba)进行中文分词
    • 过滤停用词和无意义短词
  2. TF-IDF 向量化

    • 将预处理后的文本转换为 TF-IDF 特征向量
    • TF-IDF 能有效反映词语在文档中的重要性,降低高频通用词的权重
  3. 层次聚类

    • 使用 AgglomerativeClustering,采用 Ward 连接方式
    • Ward 方法通过最小化簇内方差(within-cluster variance)来合并簇,生成的簇更加紧凑
    • 输入参数 n_clusters 控制最终切分的簇数量
  4. UUID 标识机制

    • 聚类算法输出的数字索引(0, 1, 2...)被映射为全局唯一的 UUID
    • 这样做的好处是:避免数字索引的歧义性,支持分布式场景下的唯一标识
  5. 聚类中心计算

    • 对每个簇内的所有 TF-IDF 向量取均值,得到该簇的中心向量
    • 中心向量代表了该簇的"典型语义方向"
  6. 关键词提取

    • 使用 TextRank 算法从每个簇的聚合文本中提取 Top-K 关键词
    • 关键词用于直观地理解和标注每个聚类的语义主题

第二阶段:增量预测(predict)

当有新文本到来时,系统不需要重新对所有文本进行聚类,而是采用增量方式:

yaml 复制代码
新文本 → 预处理 → TF-IDF向量化 → 与各聚类中心计算余弦相似度
                                              │
                                    ┌─────────┴──────────┐
                                    │  max_sim ≥ 阈值?    │
                                    ├─────────┬──────────┤
                                    │   Yes    │    No    │
                                    │ 归入已有簇 │ 创建新簇 │
                                    │ 更新中心   │ 分配UUID │
                                    └─────────┴──────────┘

关键设计:

  • 余弦相似度作为度量:衡量新文本向量与各聚类中心的语义接近程度
  • 阈值控制:当最大相似度低于设定阈值(如 0.3)时,认为新文本不属于任何现有簇,自动创建新簇
  • 在线更新聚类中心:当新文本归入某簇后,用滑动平均的方式更新该簇的中心向量,使中心逐步"漂移"以适应新数据

四、核心代码解析

4.1 数据结构与初始化

python 复制代码
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 文本预处理管道

python 复制代码
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 初始聚类核心逻辑

python 复制代码
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 增量预测的核心逻辑

python 复制代码
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

增量预测的关键点:

  1. 使用 transform 而非 fit_transform:新文本必须使用初始聚类时学习到的同一份词汇表,否则向量空间不一致
  2. 阈值的选择:阈值越低,新文本越容易被归入已有簇(可能引入噪声);阈值越高,越容易创建新簇(可能导致碎片化)。0.3 是一个经验起点,需根据实际数据调优
  3. 聚类中心的在线更新:采用简单均值更新的方式,类似于在线学习中的移动平均思想

4.5 聚类中心计算与关键词提取

python 复制代码
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 连接在每次合并两个簇时,选择使 合并后簇内方差增量最小 的那对簇。公式如下:

Δ(A,B)= nA⋅nB nA+nB ∥μA−μB∥2\Delta(A, B) = \frac{n_A \cdot n_B}{n_A + n_B} \| \mu_A - \mu_B \|^2 Δ(A,B)=nA+nBnA⋅nB∥μA−μB∥2

其中 nA n_A nA、 nB n_B nB 为两簇的样本数, μA \mu_A μA、 μB \mu_B μB 为两簇的质心。这意味着 Ward 倾向于合并规模相近且距离较近的簇,产生的结果通常比单链接(single linkage)或全链接(complete linkage)更加均衡。

5.3 UUID 标识体系的设计考量

less 复制代码
聚类算法输出:  [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 新用户提问的完整执行流程

聚类系统完成初始分组后,当新用户再次发起提问时,整个系统会按照以下流程运转:

less 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        用户发起提问                                  │
│                   "信用卡丢了怎么补办?"                              │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  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模板 / 自动化工具                     │
│  · 直接执行对应操作 或 返回标准化答案                                 │
│  · 无需多轮追问,实现"一句话直达"                                     │
└─────────────────────────────────────────────────────────────────────┘

另一种情况:遇到全新问题时

如果用户问的是 "今天天气怎么样?"

ini 复制代码
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 层次聚类 + 增量预测 的组合,构建了一套完整的文本自动分组系统的方案,如果你有类似的需求,欢迎在评论区一起交流。

相关推荐
Kel1 小时前
MCP 传输链路全链路拆解:从字节流到协议栈的四层架构之旅
人工智能·设计模式·架构
L3S1 小时前
Agent为什么会死循环?
人工智能·agent
云烟成雨TD2 小时前
LangFlow 1.x 系列【3】入门案例
人工智能·python·agent
renhongxia12 小时前
原生多模态对应用架构的重塑
人工智能·深度学习·机器学习·自然语言处理·架构·机器人
terryso2 小时前
BMAD Loop:把开发循环的控制权,交还给确定性代码
架构
墨流藏于库2 小时前
Electron 应用 macOS 自动更新的正确姿势 —— 没有 Apple Developer Program 也能用
agent
新知图书2 小时前
智能体基础架构
人工智能·agent·ai agent·智能体·langgraph
ASKCOS4 小时前
DeerFlow Agent 中间件架构:用插件化链彻底告别 Agent 继承式开发
中间件·架构