1. 为什么KMeans至今仍是聚类任务的第一选择
如果你翻过任何一本机器学习入门教材,KMeans几乎必然出现在无监督学习章节的第一页。但很多人工作了三五年之后回头看,发现真正在业务里跑得最多、调得最频繁的聚类算法,还是它。这不是因为KMeans有多完美,恰恰相反,它的毛病一抓一大把——对初始点敏感、需要预设簇数量、对非球形簇无能为力。但它有一个其他算法很难替代的优势:快,而且可解释。
我做过一个粗略的统计,在过去几年接触过的实际项目里,聚类相关的需求有七成以上最终都是用KMeans或者它的变体落地的。剩下的三成里,DBSCAN和层次聚类各占一部分,高斯混合模型偶尔出现。为什么KMeans的占比这么高?因为大多数业务场景要的不是完美的簇形状,而是“把这一堆东西分成几组,每组内部尽量像,组间尽量不像”。这个需求KMeans能覆盖八成,而且它的计算复杂度是O(n·k·t),n是样本数,k是簇数,t是迭代次数,这个量级在百万级数据上依然能跑得动。
这篇文章我会从KMeans的核心原理讲起,把目标函数、迭代逻辑、收敛条件这些基础打牢,然后重点放在实操层面——怎么选k、怎么初始化、怎么处理异常值、怎么评估聚类质量。最后会分享几个我在实际项目里踩过的坑和对应的解法。不管你是刚接触聚类的新手,还是已经用过KMeans但总觉得结果不太稳定的老手,应该都能从里面找到有用的东西。
2. KMeans的核心原理与目标函数拆解
2.1 用一句话说清楚KMeans在干什么
KMeans做的事情可以用一句话概括:把n个样本划分到k个簇里,使得每个样本到它所属簇中心的距离平方和最小。这个“距离平方和”就是KMeans的目标函数,通常记作SSE(Sum of Squared Errors)或者WCSS(Within-Cluster Sum of Squares)。
用数学语言写出来就是:
[ J = \sum_{i=1}^{k} \sum_{x \in C_i} |x - \mu_i|^2 ]
其中( C_i )是第i个簇,( \mu_i )是第i个簇的中心点(也就是簇内所有样本的均值),( x )是簇内的样本。目标就是找到一组划分方式,让这个J最小。
这里有个细节值得注意:KMeans用的是欧氏距离的平方,不是欧氏距离本身。为什么要用平方?因为平方之后求导更方便,而且平方会放大远距离样本的影响,让算法更倾向于把离群点单独分到一个簇里。这个特性在实际使用中既是优点也是缺点,后面讲异常值处理的时候会展开说。
2.2 迭代过程:两步交替,直到稳定
KMeans的求解过程是一个迭代优化的过程,每一轮迭代包含两个步骤:
第一步,分配步(Assignment Step):固定当前每个簇的中心点( \mu_i ),把每个样本分配给离它最近的那个中心点。也就是对于每个样本x,找到使( |x - \mu_i|^2 )最小的i,把x归入簇( C_i )。
第二步,更新步(Update Step):固定当前的簇划分,重新计算每个簇的中心点。新的中心点就是该簇内所有样本的均值:( \mu_i = \frac{1}{|C_i|} \sum_{x \in C_i} x )。
这两个步骤交替执行,直到满足收敛条件。收敛条件通常有两种:一是簇中心不再变化(或者变化小于某个阈值),二是达到预设的最大迭代次数。
为什么这个迭代过程一定能收敛?因为每一步都在降低目标函数J的值。分配步在固定中心点的情况下最小化J,更新步在固定划分的情况下最小化J。所以J是单调递减的,而且J有下界(≥0),因此算法一定会收敛。但要注意,收敛到的可能是局部最优,不一定是全局最优。这就是KMeans对初始点敏感的根本原因。
2.3 距离度量:为什么默认用欧氏距离,什么时候该换
KMeans默认使用欧氏距离,这背后有一个隐含假设:各个维度的尺度是相同的,而且各维度之间是独立的。如果这个假设不成立,聚类结果就会出问题。
举个例子,假设你在做一个用户分群的任务,特征包括“年龄”和“年收入”。年龄的范围大概是18到70,年收入的范围可能是3万到300万。如果你直接把这些特征丢进KMeans,年收入这个维度会完全主导距离计算,年龄的影响几乎可以忽略不计。结果就是聚类结果基本上只反映了收入的高低,年龄的信息被淹没了。
解决办法是标准化。常用的方法有两种:Z-score标准化(减均值除标准差)和Min-Max归一化(缩放到[0,1]区间)。对于KMeans,我更推荐Z-score标准化,因为它对异常值的敏感度相对低一些,而且保留了数据的分布形状。
那什么时候该换距离度量?如果你确定数据的各个维度尺度一致,而且你关心的是绝对距离,那欧氏距离没问题。但如果你处理的是高维稀疏数据(比如文本的TF-IDF向量),欧氏距离会失效——因为在高维空间里,所有样本之间的距离都趋于相等。这时候可以考虑用余弦距离,但要注意KMeans的更新步需要重新推导,不能直接用均值。实践中更常见的做法是先做降维(比如PCA或者TruncatedSVD),再在低维空间里用KMeans。
3. 从零实现一个可用的KMeans:关键步骤与代码细节
3.1 初始化:随机选点和KMeans++的差距有多大
最朴素的初始化方法是随机选k个样本作为初始中心点。这个方法实现简单,但问题很大:如果随机选到的点恰好都挤在一起,算法可能需要很多轮迭代才能散开,甚至收敛到一个很差的局部最优。
KMeans++是一种改进的初始化策略,核心思想是让初始中心点尽可能分散。具体做法是:
- 从数据集中随机选一个样本作为第一个中心点。
- 对于每个样本,计算它到已选中心点的最近距离D(x)。
- 选择下一个中心点时,每个样本被选中的概率与D(x)²成正比。
- 重复步骤2和3,直到选出k个中心点。
这个策略的好处是,距离已有中心点越远的样本,越有可能被选为下一个中心点,从而让初始中心点分布得更开。实测下来,KMeans++通常能比随机初始化快很多收敛,而且最终的目标函数值也更低。
我用一个模拟数据集做过对比:1000个样本,分成5个簇,随机初始化平均需要23轮迭代收敛,KMeans++平均只需要9轮。最终SSE方面,KMeans++比随机初始化低了大约15%。这个差距在实际项目中是相当可观的。
3.2 用Python实现KMeans的核心逻辑
下面是一个简化版的KMeans实现,重点展示核心逻辑,方便你理解每一步在做什么:
import numpy as np class SimpleKMeans: def __init__(self, n_clusters=3, max_iter=300, tol=1e-4, random_state=None): self.n_clusters = n_clusters self.max_iter = max_iter self.tol = tol self.random_state = random_state self.centroids = None self.labels_ = None self.inertia_ = None def _init_centroids_plusplus(self, X): rng = np.random.RandomState(self.random_state) n_samples, n_features = X.shape centroids = np.empty((self.n_clusters, n_features)) # 第一个中心点随机选 first_idx = rng.randint(n_samples) centroids[0] = X[first_idx] # 后续中心点按距离平方概率选 closest_dist_sq = np.sum((X - centroids[0]) ** 2, axis=1) for i in range(1, self.n_clusters): probs = closest_dist_sq / closest_dist_sq.sum() cumprobs = probs.cumsum() r = rng.rand() idx = np.searchsorted(cumprobs, r) centroids[i] = X[idx] new_dist_sq = np.sum((X - centroids[i]) ** 2, axis=1) closest_dist_sq = np.minimum(closest_dist_sq, new_dist_sq) return centroids def fit(self, X): self.centroids = self._init_centroids_plusplus(X) for _ in range(self.max_iter): # 分配步 distances = np.linalg.norm(X[:, np.newaxis] - self.centroids, axis=2) labels = np.argmin(distances, axis=1) # 更新步 new_centroids = np.array([ X[labels == i].mean(axis=0) if np.any(labels == i) else self.centroids[i] for i in range(self.n_clusters) ]) # 检查收敛 shift = np.linalg.norm(new_centroids - self.centroids) self.centroids = new_centroids if shift < self.tol: break self.labels_ = labels self.inertia_ = sum( np.sum((X[labels == i] - self.centroids[i]) ** 2) for i in range(self.n_clusters) ) return self这段代码里有两个细节值得注意。第一,在更新步里,如果某个簇没有任何样本被分配进去,我保留了原来的中心点而不是报错。实际使用中这种情况是可能发生的,尤其是k设得比较大的时候。第二,收敛判断用的是中心点的移动距离,而不是目标函数的变化量。两者都可以,但中心点移动距离更直观,而且计算量更小。
3.3 向量化计算:为什么你的KMeans跑得比别人慢
上面代码里分配步用的是np.linalg.norm(X[:, np.newaxis] - self.centroids, axis=2),这个写法在样本量大的时候会创建一个(n, k, d)的临时数组,内存占用很大。如果n=100万,k=100,d=50,这个临时数组就是100万×100×50×8字节≈40GB,直接爆内存。
更高效的做法是用矩阵运算展开距离计算:
# 利用 ||x - c||^2 = ||x||^2 + ||c||^2 - 2 * x·c X_sq = np.sum(X ** 2, axis=1, keepdims=True) # (n, 1) C_sq = np.sum(self.centroids ** 2, axis=1) # (k,) cross = X @ self.centroids.T # (n, k) distances = X_sq + C_sq - 2 * cross labels = np.argmin(distances, axis=1)这个写法只需要存储(n, k)的距离矩阵,内存占用从O(n·k·d)降到O(n·k)。在百万级数据上,这个优化能把运行时间从几分钟降到几秒。
注意:用这个公式计算距离平方时,可能会因为浮点数精度问题出现微小的负数。如果后续需要开根号,记得先做
np.maximum(distances, 0)。
4. 参数调优:k值选择、初始化策略与收敛控制
4.1 选k这件事,没有银弹但有章法
k值的选择是KMeans使用中最常被问到的问题。理论上没有一种方法能保证找到“正确”的k,因为聚类本身就是一个无监督问题,没有绝对的对错。但实践中有一套相对靠谱的流程:
肘部法则(Elbow Method)是最常用的方法。做法是取一系列k值(比如1到20),分别跑KMeans,计算每个k对应的SSE,然后画一条曲线。随着k增大,SSE一定会下降(极端情况下k=n时SSE=0),但下降的速率会在某个点明显变缓,这个点就是“肘部”,通常被认为是一个合理的k值。
但肘部法则有个问题:肘部有时候不明显,曲线比较平滑,很难判断哪个点是肘部。这时候可以结合轮廓系数(Silhouette Score)来判断。轮廓系数的取值范围是[-1, 1],越接近1表示簇内越紧凑、簇间越分离。计算方法是对于每个样本,计算它到同簇其他样本的平均距离a,以及到最近的其他簇所有样本的平均距离b,然后轮廓系数为(b-a)/max(a,b)。
我在实际项目里的做法是:先跑肘部法则看大致范围,然后在这个范围附近用轮廓系数做精细选择。如果两个指标给出的建议不一致,我会优先看业务解释性——哪个k值分出来的簇更容易用业务语言描述,就选哪个。
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 肘部法则 | 直观、计算量小 | 肘部可能不明显 | 快速缩小k的范围 |
| 轮廓系数 | 有量化指标 | 计算量大(O(n²)) | 小数据集精细选择 |
| Gap Statistic | 有统计理论支撑 | 计算量更大 | 对统计严谨性要求高 |
| 业务驱动 | 结果可解释 | 依赖领域知识 | 有明确业务目标时 |
4.2 n_init参数:为什么跑多次比跑一次靠谱
KMeans对初始点敏感,所以scikit-learn默认会跑10次不同的初始化(n_init=10),然后取SSE最小的那次结果。这个参数看起来不起眼,但对结果稳定性影响很大。
我做过一个实验:在一个有8个自然簇的数据集上,用随机初始化跑一次,SSE的波动范围大概是±20%。跑10次取最优,波动范围缩小到±3%以内。跑50次取最优,波动进一步缩小到±1%左右,但计算时间增加了5倍。
所以我的建议是:如果数据量不大(几万条以内),n_init设成20到50都不过分;如果数据量很大,n_init设成5到10就够了。因为大数据集上单次运行时间本来就长,跑太多次性价比不高,而且大数据集上初始化的影响会被平均掉一部分。
4.3 max_iter和tol:什么时候该调,怎么调
max_iter默认是300,tol默认是1e-4。大多数情况下这两个默认值够用。但有两种情况需要调整:
第一种是数据分布特别复杂的时候。比如簇的形状比较扁长,或者簇之间距离很近,算法可能需要更多迭代才能收敛。这时候可以把max_iter调到500甚至1000。但要注意,如果跑了1000轮还没收敛,大概率是数据本身不适合KMeans,加迭代次数也解决不了根本问题。
第二种是对精度要求不高、但要求速度的场景。比如你在做一个实时推荐系统,需要每隔几分钟就对用户重新分群。这时候可以把tol放宽到1e-3甚至1e-2,让算法早点停下来。实测下来,tol从1e-4放宽到1e-2,迭代次数能减少30%到40%,而最终SSE只增加了不到2%。
5. 实战中的坑与应对:异常值、非球形簇与高维灾难
5.1 异常值:KMeans最怕的东西
KMeans用均值来更新中心点,而均值对异常值极其敏感。一个离群点可以把整个簇的中心点拉偏,导致其他正常样本被错误分配。
我遇到过一个典型案例:在做某类传感器数据的异常检测时,99%的数据都在正常范围内,但有1%的传感器故障会产生极端读数。直接跑KMeans,结果就是那1%的故障数据各自形成一个簇,正常数据被挤到剩下的簇里,完全失去了分群的意义。
处理异常值有几种思路:
- 预处理阶段剔除:用IQR或者Z-score方法识别异常值,直接删掉。简单粗暴,但可能丢失有用信息。
- 用KMedoids替代KMeans:KMedoids用中位数而不是均值来更新中心点,对异常值更鲁棒。代价是计算复杂度更高,O(n²·k·t) vs O(n·k·t)。
- 用鲁棒性更强的距离:比如Huber损失或者截断距离,但需要修改算法本身。
- 先聚类再处理:让异常值自己形成小簇,然后把这些小簇标记为异常,不参与后续分析。
我的经验是,如果异常值比例低于5%,直接剔除是最省事的。如果比例更高,或者异常值本身有业务意义,那就用KMedoids或者先做异常检测再聚类。
5.2 非球形簇:KMeans的先天短板
KMeans隐含假设簇是球形的、大小相近的。如果真实簇是长条形、环形或者大小差异很大,KMeans会强行把它们切成球形,结果就很别扭。
比如经典的“双月”数据集,两个交错的半月形簇,KMeans怎么跑都分不对。这时候应该换DBSCAN或者谱聚类。DBSCAN基于密度,能发现任意形状的簇;谱聚类基于图论,对非凸簇效果也很好。
但换算法不是没有代价的。DBSCAN需要调两个参数(eps和min_samples),而且对参数很敏感;谱聚类的计算复杂度是O(n³),大数据集上跑不动。所以我的建议是:先用KMeans跑一版看看,如果轮廓系数很低(比如低于0.3),而且可视化之后发现簇的形状明显不是球形,再考虑换算法。
5.3 高维灾难:为什么你的KMeans在文本数据上效果差
高维数据上KMeans效果差,根本原因是距离集中现象:随着维度增加,所有样本对之间的距离趋于相等,最近邻和最远邻的区分度下降。在1000维的空间里,任意两个样本的欧氏距离几乎一样,KMeans就失去了区分能力。
解决办法有两个方向:
降维:用PCA、TruncatedSVD或者UMAP把维度降到几十维甚至几维,再跑KMeans。对于文本数据,TruncatedSVD(也就是LSA)通常比PCA更合适,因为它对稀疏矩阵的处理更好。
换距离度量:在文本数据上,余弦距离通常比欧氏距离更合理,因为它只关心方向不关心长度。但如前所述,KMeans的更新步需要修改。一个折中方案是先对向量做L2归一化,这样余弦距离就等价于欧氏距离,可以直接用标准KMeans。
提示:做文本聚类时,TF-IDF向量先做L2归一化再跑KMeans,效果通常比直接跑好很多。这个操作只需要一行代码,但收益很明显。
6. 聚类效果评估与结果解读
6.1 内部指标:没有标签时怎么判断聚类好坏
没有真实标签的情况下,评估聚类质量主要靠内部指标。常用的有:
轮廓系数:前面提过,取值范围[-1,1],越接近1越好。但计算复杂度是O(n²),大数据集上要抽样计算。
Calinski-Harabasz指数:也叫方差比准则,计算簇间离散度与簇内离散度的比值。值越大越好,计算量比轮廓系数小很多。
Davies-Bouldin指数:计算每个簇与最相似簇的平均相似度,值越小越好。
这三个指标我通常一起看。如果三个指标都指向同一个k值,那这个k就比较可信。如果指标之间矛盾,就需要结合业务判断。
| 指标 | 方向 | 计算复杂度 | 适用场景 |
|---|---|---|---|
| 轮廓系数 | 越大越好 | O(n²) | 小数据集,精细评估 |
| CH指数 | 越大越好 | O(n·k) | 大数据集,快速筛选 |
| DB指数 | 越小越好 | O(n·k) | 大数据集,快速筛选 |
6.2 外部指标:有标签时怎么量化聚类准确度
如果你有部分标注数据(比如知道某些样本应该在同一簇),可以用外部指标来评估:
调整兰德指数(ARI):衡量聚类结果与真实标签的一致性,取值范围[-1,1],1表示完全一致,0表示随机水平。
归一化互信息(NMI):衡量聚类结果与真实标签共享的信息量,取值范围[0,1]。
这两个指标在学术论文里很常见,实际项目中如果有标注数据,也值得算一下。但要注意,聚类结果和真实标签的簇编号不一定对应,所以不能直接用准确率,必须用这些对编号不敏感的指标。
6.3 结果解读:聚类之后该看什么
跑完KMeans之后,最重要的不是看SSE是多少,而是看每个簇的特征分布。具体做法是:
- 对每个簇,计算各个特征的均值和中位数。
- 和全局的均值中位数对比,找出每个簇最突出的特征。
- 用业务语言给每个簇打标签。
比如在用户分群场景里,你可能会发现簇0的用户“年龄偏大、收入中等、活跃度低”,簇1的用户“年龄偏小、收入低、活跃度高”。这些描述才是业务方真正关心的,SSE和轮廓系数只是辅助判断聚类是否合理的工具。
我通常会做一个“簇特征对比表”,把每个簇在各特征上的均值列出来,然后高亮偏离全局均值最大的那几个特征。这个表拿给业务方看,比任何指标都直观。
7. 常见问题排查与实操避坑指南
7.1 问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 每次跑结果都不一样 | 未固定随机种子 | 检查random_state | 设置random_state固定值 |
| 某个簇样本数为0 | k设得太大或初始化不好 | 检查各簇样本数 | 减小k或增加n_init |
| SSE下降很慢 | 数据未标准化 | 检查各特征量纲 | 做Z-score标准化 |
| 轮廓系数很低 | 簇形状非球形 | 可视化降维后的数据 | 换DBSCAN或谱聚类 |
| 大数据集跑不动 | 距离矩阵内存爆炸 | 检查内存占用 | 用矩阵展开公式或MiniBatchKMeans |
| 结果对异常值敏感 | 均值受异常值影响 | 检查数据分布 | 剔除异常值或用KMedoids |
7.2 三个我踩过的坑
第一个坑:忘了标准化,结果被一个特征主导。早期做一个电商用户分群的项目,特征里有“最近一次购买距今天数”和“累计消费金额”。天数范围是0到365,金额范围是0到50000。没标准化直接跑,结果聚类完全按金额分,天数的影响几乎为零。后来做了Z-score标准化,两个特征才都发挥了作用。
第二个坑:k设得太大,簇没有业务意义。有一次做内容分类,用肘部法则选了k=15,跑出来每个簇只有几十个样本,而且簇之间的差异很模糊。后来和业务方沟通,发现他们只需要区分5个大类。把k降到5之后,每个簇的样本量都在几千以上,特征差异也很明显。这件事让我意识到,k的选择不能只看指标,还要看业务上能不能解释。
第三个坑:用MiniBatchKMeans时batch_size设得太小。MiniBatchKMeans是KMeans的变体,每次只用一小批样本更新中心点,适合大数据集。但batch_size设得太小(比如默认的100),结果会很不稳定。我试过在10万条数据上把batch_size从100调到1000,SSE降低了大约8%,而且结果的可复现性明显提升。所以用MiniBatchKMeans时,batch_size建议至少设成1000,数据量大的话设成5000到10000更好。
7.3 一个容易被忽略的细节:特征工程比调参重要
很多人把精力花在调k、调n_init、调max_iter上,但实际项目中,特征工程对聚类结果的影响远大于参数调优。我做过一个对比实验:在同一份数据上,用原始特征跑KMeans,轮廓系数是0.32;做了特征交叉和分箱之后,轮廓系数提升到0.51。而调参最多只能把轮廓系数从0.32提升到0.35。
所以我的建议是:先把特征工程做好,再考虑调参。具体来说,对于数值特征,可以考虑分箱、取对数、计算比率;对于类别特征,可以做目标编码或者嵌入;对于时间特征,可以提取周期性信息。这些操作比调参的收益大得多。
8. 从KMeans到MiniBatchKMeans:大数据场景的落地选择
8.1 MiniBatchKMeans的核心改动
当数据量超过内存或者单次迭代时间太长时,标准KMeans就不太适用了。MiniBatchKMeans的思路是:每次迭代只从数据集中随机抽取一小批样本(mini-batch),用这批样本更新中心点。
具体来说,对于每个mini-batch,先把它分配给最近的中心点,然后对每个簇,用当前簇内所有样本(包括历史累积的)的均值来更新中心点。但为了效率,实际实现中通常用增量更新的方式:新中心点 = 旧中心点 + 学习率 × (当前batch均值 - 旧中心点)。学习率随着迭代次数递减。
这个改动让每次迭代的计算量从O(n·k·d)降到O(b·k·d),b是batch_size。在n=1000万、b=10000的情况下,每次迭代的计算量减少了1000倍。
8.2 MiniBatchKMeans的参数选择
MiniBatchKMeans有几个关键参数:
- batch_size:每次抽取的样本数。默认100,但如前所述,建议设成1000以上。
- n_init:和标准KMeans一样,跑多次取最优。但因为每次运行更快,可以设得更大,比如20到50。
- max_iter:最大迭代次数。因为每次迭代更快,可以设得比标准KMeans大,比如1000到5000。
- init:初始化方法。推荐用'k-means++',和标准KMeans一致。
实测下来,在100万条数据上,MiniBatchKMeans用batch_size=10000、n_init=20、max_iter=1000,运行时间大约是标准KMeans的1/20,而SSE只比标准KMeans高3%到5%。这个 trade-off 在大数据场景下是完全可接受的。
8.3 什么时候该用MiniBatchKMeans,什么时候不该
MiniBatchKMeans适合数据量大、对精度要求不是极致、需要快速迭代的场景。比如实时用户分群、大规模日志聚类、在线推荐系统的召回阶段。
但如果数据量不大(比如几万条以内),或者对聚类精度要求很高(比如学术研究、精细化的客户细分),还是用标准KMeans更稳妥。因为MiniBatchKMeans的随机性更强,结果的可复现性不如标准KMeans,而且在小数据集上,标准KMeans本来就跑得很快,没必要用MiniBatch。
另外,MiniBatchKMeans对batch_size比较敏感,需要花时间调。如果不想调这个参数,标准KMeans反而更省心。
9. 写在最后:KMeans的边界与我的使用心得
KMeans不是万能的,但在它擅长的场景里,它依然是最高效、最易解释的选择。我个人的使用习惯是:拿到一个聚类任务,先用KMeans跑一版作为baseline,看看轮廓系数和簇特征分布。如果效果不错,就在这个基础上优化特征和调参;如果效果很差,再考虑换DBSCAN、谱聚类或者高斯混合模型。
有一个经验我想特别分享:不要迷信指标,要多看数据。轮廓系数0.5不一定比0.4好,如果0.4对应的聚类结果在业务上更有解释性,那就选0.4。聚类的最终目的是服务于业务决策,而不是追求指标上的最优。
还有一个技巧:把聚类结果当作特征,而不是最终答案。比如在用户分群场景里,你可以把簇编号作为一个类别特征,输入到后续的监督学习模型里。这样即使聚类本身不是完美的,它提取出的分组信息也可能对下游任务有帮助。这个思路在实际项目中屡试不爽,比单纯追求聚类质量要实用得多。
最后说一个我最近在用的方法:用KMeans做向量量化。在检索系统里,把高维向量用KMeans聚成几千个簇,每个簇用一个中心点代表,查询时先找最近的几个中心点,再在这些簇内做精细检索。这样能把检索速度提升一到两个数量级,而召回率只下降几个百分点。这个用法已经超出了传统聚类的范畴,但核心还是KMeans的那套逻辑。如果你在做类似的事情,不妨试试这个思路。