Blog Galaxy 背后的文本聚类算法:从语义嵌入到二维可视化

为什么需要文本聚类

博客写了一段时间后,文章数量逐渐增多。当文章超过 50 篇时,单纯的时间线列表已经不能很好地帮助读者(和自己)理解内容的组织结构——哪些文章讨论相似话题?不同主题之间有什么关系?有没有「离群」文章,写了奇怪的东西?

这些问题本质上都是聚类问题。本文以 Misaka Network Blog 的 Blog Galaxy 系统为例,拆解从原始 Markdown 文章到二维交互式星系图的完整算法流水线。

流水线总览

整个系统分为五个阶段,核心计算在构建时完成,前端只做渲染:

flowchart TD
    A[Markdown 文章] --> B[文本预处理]
    B --> C[BAAI/bge-m3<br/>语义嵌入 1024D]
    C --> D[UMAP 降维<br/>1024D → 50D]
    D --> E[HDBSCAN<br/>密度聚类]
    E --> F[UMAP 降维<br/>50D → 2D]
    F --> G[clusters.json]
    G --> H[ECharts<br/>散点图渲染]

第一阶段:文本转向量——语义嵌入

什么是语义嵌入

计算机不认识文字,只认识数字。要把”这篇文章在讲什么”交给机器学习模型处理,首先需要将文本转换为数学表示——一个固定维度的向量。

关键要求是:语义相近的文章,向量之间的距离也相近。换句话说,两篇都讲”Astro 博客开发”的文章,它们的向量应该在多维空间中靠得很近;而一篇讲”Astro 开发”、一篇讲”蒸汽机车原理”,它们的向量应该离得很远。

为什么选 BAAI/bge-m3

本项目选用了阿里巴巴发布的 BAAI/bge-m3 模型,决策理由有三:

考量bge-m3 的优势
多语言原生支持中英文混合内容,博客大量文章是中英混杂的
语义质量MTEB 基准排名靠前,dense retrieval 场景表现优异
向量归一化输出 L2-normalized 向量,后续的余弦相似度计算天然高效

模型的推理过程:

输入: "Astro 6 是一个现代化的静态站点生成器,支持 Markdown..."

bge-m3 编码器(12 层 Transformer)

输出: [0.023, -0.047, 0.081, ..., -0.019]  (1024 维归一化向量)

为了一致性和可复现性,配置了固定随机种子(seed=42),使用 fp16 精度在 GPU 上推断,对 CPU 环境自动降级为 fp32。最大序列长度设为 1024 token,对博客文章的典型长度来说足够覆盖核心语义。

文本预处理

在送入模型之前,对 Markdown 做了清洗:

def strip_markdown(text: str) -> str:
    cleaned = re.sub(r"```.*?```", " ", text, flags=re.S)  # 移除代码块
    cleaned = re.sub(r"`[^`]*`", " ", cleaned)             # 移除行内代码
    cleaned = re.sub(r"!\[([^\]]*)\]\([^\)]*\)", r"\1", cleaned)  # 图片→alt text
    cleaned = re.sub(r"\[([^\]]+)\]\([^\)]*\)", r"\1", cleaned)   # 链接→文本
    cleaned = re.sub(r"<[^>]+>", " ", cleaned)             # 移除 HTML
    cleaned = re.sub(r"[#>*_~`]+", " ", cleaned)           # 移除格式标记
    cleaned = re.sub(r"\s+", " ", cleaned)                 # 压缩空白
    return cleaned.strip()

这个步骤的核心思想是:代码块、链接、HTML 标签对文章的”语义”贡献很小,反而会引入噪声。图片的 alt 文本比图片本身更能反映内容意图。

第二阶段:降维准备——UMAP 的第一步

为什么需要降维

1024 维向量对计算机来说不是问题,但对聚类算法是个难题——高维空间中,几乎所有的点都倾向于彼此”等距离”(这就是”维度灾难”)。直接在 1024 维上做聚类,结果几乎是随机的。

UMAP(Uniform Manifold Approximation and Projection)做的事情是在保留数据局部结构的前提下,将高维数据映射到低维空间。

两阶段 UMAP 策略

本项目使用了两个 UMAP 阶段,参数完全不同:

flowchart LR
    subgraph Stage1["Stage 1: 聚类准备"]
        A1[1024D 向量] -->|UMAP<br/>cosine / min_dist=0.0 / 50D| B1[50D 紧凑表示]
    end
    subgraph Stage2["Stage 2: 可视化"]
        A2[50D 聚类结果] -->|UMAP<br/>euclidean / min_dist=0.1 / 2D| B2[2D 坐标]
    end

Stage 1 参数设计的思路:

clusterable_embedding = umap.UMAP(
    n_neighbors=min(15, len(posts) - 1),  # 保留局部结构
    min_dist=0.0,                          # 允许点紧贴在一起
    n_components=min(50, max(2, len(posts) - 2)),  # 中间维度
    metric='cosine',                       # 余弦距离 = 语义距离
    random_state=42
).fit_transform(embeddings)
  • min_dist=0.0:不强制分散点。语义上相似的文章,在降维空间中就应该紧贴在一起,这恰好是聚类算法需要的——紧凑的簇更易于识别
  • metric='cosine':因为我们用的是 L2-normalized 向量,余弦距离就是向量夹角的度量,完美对应”语义相似度”
  • n_neighbors:动态计算为 min(15, len(posts)-1),文章少时自动缩减

Stage 2 的参数变化只有一个——min_dist 从 0.0 提升到 0.1。这个微调让 2D 可视化中的点稍微舒展一些,避免文字重叠,但不改变聚类的本质结构。

第三阶段:密度聚类——HDBSCAN

KMeans 的问题

传统的 KMeans 聚类有几个致命弱点:

graph TD
    subgraph KMeans["KMeans 的问题"]
        K1["需要预设 K 值<br/>但语义簇的数量是未知的"]
        K2["假设球形分布<br/>真实语义簇几乎不是球形的"]
        K3["所有点必须归入某个簇<br/>无法识别离群点"]
    end

HDBSCAN 的原理

HDBSCAN(Hierarchical Density-Based Spatial Clustering of Applications with Noise)采用完全不同的思路:

  1. 密度估计:计算每个点周围的邻居密度
  2. 构建层次结构:按密度阈值生成不同”分辨率”的聚类树
  3. 提取稳定簇:选择在最大密度范围内持续稳定的簇

核心优势可以在一个例子中直观理解:一篇讲”Espresso 咖啡萃取参数”的文章,在 KMeans 下可能被强行归入”美食”簇或”科学实验”簇。而 HDBSCAN 会说:“这文章的语义邻居密度不够高,标记为 noise(cluster = -1)“——因为它既不够”美食”也不够”科学”,它是一个离群点。

参数选择

clusterer = hdbscan.HDBSCAN(
    min_cluster_size=min(5, max(3, len(posts) // 10)),
    min_samples=3,
    metric='euclidean'
)
  • min_cluster_size:动态计算。对于 30 篇文章,一个簇至少需要 3 篇;对于 60 篇,至少需要 5 篇。避免在小数据集上产生”僵尸簇”
  • min_samples=3:一个点需要至少 3 个邻居才能被视为”稠密区域”。这个值设得较低,因为博客文章总数本身就不大(通常 50~200 篇),过高的阈值会导致大量点被标记为 noise

KMeans → HDBSCAN 的演变

这个项目最早确实用的是 KMeans。切换到 HDBSCAN 后,聚类质量有质的提升:

对比维度KMeans(旧)HDBSCAN(新)
簇数量固定 5 个自适应
噪声处理无,强行分配标记为 cluster: -1
簇形状球形任意形状
参数敏感性高度依赖 K 的选择min_cluster_size 有直观含义

值得注意的是,HDBSCAN 的噪声标记在前端也有对应的视觉处理——噪声点渲染为半透明灰色小圆点,与彩色的大圆点(正常聚类)形成视觉区分。

第四阶段:二维投影与可视化

50D → 2D 的挑战

50 维降到 2 维是信息损失最大的步骤。UMAP 的 Stage 2 需要在”保持聚类结构”和”让图表好看”之间取得平衡:

vis_embedding = umap.UMAP(
    n_neighbors=min(15, len(posts) - 1),
    min_dist=0.1,      # ← 关键参数
    n_components=2,
    metric='euclidean',
    random_state=42
).fit_transform(clusterable_embedding)

为什么这里用 euclidean 而不是 cosine?因为在 UMAP 已经处理过的 50D 空间中,点已经被 cosine 度量优化过了,聚类结构已经形成。此时用 euclidean 做可视化投影,更关注几何布局,而不是语义度量——这恰好是”好看”所需要的。

clusters.json 的数据格式

[
  {
    "title": "Astro 静态网站生成:现代 Web 开发的新选择",
    "slug": "z20L2VWo",
    "date": "2025-01-20",
    "cluster": 1,
    "x": -1.904,
    "y": -2.058
  }
]

slug 对应文章 frontmatter 中的短链接 ID(NanoID),前端点击散点后跳转 /blog/{slug}

ECharts 渲染层

前端使用 ECharts 的 scatter chart(散点图),每个点都是一篇文章。关键配置:

  • Canvas 渲染器:散点图场景下比 SVG 性能更好
  • DataZoom 交互:支持鼠标滚轮缩放和拖拽平移
  • 20% 轴余量:坐标轴范围比数据实际范围多 20%,提供缩小空间
  • 主题响应式:监听 theme-changed 事件,深色/浅色切换时重新从 CSS 变量读取颜色

每个簇分配一个品牌色,颜色从 Tailwind 自定义主题 CSS 变量中读取(--misaka-blue--misaka-circuit 等),确保与博客整体设计语言一致。

// 噪声点使用半透明灰色
itemStyle: {
  color: item.cluster === -1
    ? 'rgba(100, 116, 139, 0.3)'
    : palette[item.cluster % palette.length],
}

边界情况处理

真实项目中有几个容易翻车的边界情况,都做了防御性处理:

只有 1 篇文章:直接放在原点 [0, 0],跳过所有 ML 步骤

只有 2 篇文章:手动分到两个簇,坐标设为 [-1, 0][1, 0]

文章无正文内容:有些文章只有 frontmatter 和一张图,此时将 title 作为 embedding 输入,确保不出现空向量

GPU 不可用:自动降级到 CPU 推理,精度从 fp16 切换为 fp32

本地模型不可用:优先使用预下载到 model/ 目录的本地快照,降级方案才从 HuggingFace 在线下载

可复现性

整个流水线的随机性由固定 seed 控制:

# UMAP 两次 transform 均使用 fixed seed
random_state=42

HDBSCAN 本身是确定性的(给定输入,输出不变)。这意味着:相同的文章集合,在任何机器上运行都会产生完全相同的坐标——这不是偶然,而是刻意设计的选择。一个可复现的星系图,意味着你可以在本地和 CI 环境中得到一致的结果。

前端渲染是即时的——所有计算在构建时完成,浏览器只需要加载 JSON 并交给 ECharts 绘制。

总结

整个 Blog Galaxy 系统的聚类流水线可以归结为几个核心设计理念:

  1. 语义嵌入是地基——选一个好的 embedding 模型比调任何聚类参数都重要,bge-m3 的多语言能力和语义质量是关键
  2. 降到低维再做聚类——直接在 1024 维上做聚类是自讨苦吃,UMAP 的两阶段策略(紧致聚类 + 舒展可视化)是这个系统的精髓
  3. 不要预设簇数量——HDBSCAN 替代 KMeans 是质的飞跃,密度驱动的聚类更贴合语义空间的真实结构;噪声标记也让”无法归类的内容”有了正当的位置
  4. 构建时计算,运行时渲染——所有 ML 都在 npm run update-graph 时完成,前端只负责把 JSON 画成散点图
  5. 可复现 > 花哨——固定 seed 确保每次运行结果一致,这对调试和维护至关重要

如果你也想为博客文章做类似的聚类可视化,可以从最简单的方案开始:文本 → 某个 embedding 模型 → PCA 降维 → KMeans → 散点图。能跑了之后,再把 PCA 换成 UMAP、KMeans 换成 HDBSCAN,步步为营地迭代。