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)采用完全不同的思路:
- 密度估计:计算每个点周围的邻居密度
- 构建层次结构:按密度阈值生成不同”分辨率”的聚类树
- 提取稳定簇:选择在最大密度范围内持续稳定的簇
核心优势可以在一个例子中直观理解:一篇讲”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 系统的聚类流水线可以归结为几个核心设计理念:
- 语义嵌入是地基——选一个好的 embedding 模型比调任何聚类参数都重要,bge-m3 的多语言能力和语义质量是关键
- 降到低维再做聚类——直接在 1024 维上做聚类是自讨苦吃,UMAP 的两阶段策略(紧致聚类 + 舒展可视化)是这个系统的精髓
- 不要预设簇数量——HDBSCAN 替代 KMeans 是质的飞跃,密度驱动的聚类更贴合语义空间的真实结构;噪声标记也让”无法归类的内容”有了正当的位置
- 构建时计算,运行时渲染——所有 ML 都在
npm run update-graph时完成,前端只负责把 JSON 画成散点图 - 可复现 > 花哨——固定 seed 确保每次运行结果一致,这对调试和维护至关重要
如果你也想为博客文章做类似的聚类可视化,可以从最简单的方案开始:文本 → 某个 embedding 模型 → PCA 降维 → KMeans → 散点图。能跑了之后,再把 PCA 换成 UMAP、KMeans 换成 HDBSCAN,步步为营地迭代。