文件夹里堆了几千份文档,有技术方案、会议纪要、报销单据、合同扫描件的导出文本,想按照内容把它们整理归类,靠手动一个个看显然不现实。这类问题正好可以用文本聚类来解决:让程序读取每份文档的内容,把文字转换成计算机能理解的数值向量,再根据向量之间的距离自动把相似的文档划到同一组。本文用C#从零实现一套完整的文档聚类流程,涵盖特征提取、相似度计算和K-Means分组,代码可以直接拿去改造使用。

一、文本聚类的基本原理
文本聚类的核心思想是把“文档相似”这个模糊的概念变成可计算的数学问题。计算机无法直接比较两段文字的含义,但可以把每份文档表示成一个高维向量,向量中的每一个维度对应一个词语,数值代表这个词在文档中的重要程度。这样一来,两份文档是否相似,就转化为两个向量在空间中是否接近。
整个流程通常分为四步:第一步读取文档内容并做文本预处理,包括分词、去掉无意义的停用词(如“的”“了”“是”这类词);第二步用TF-IDF算法计算每个词的权重,生成文档向量;第三步用余弦相似度衡量文档两两之间的接近程度;第四步用聚类算法(最常用的是K-Means)把相似的文档归到同一簇。理解了这条链路,后面的代码实现就水到渠成了。
二、读取文档并做预处理
先解决文档读取问题。常见格式里,txt最简单,直接用File.ReadAllText读取即可;docx本质是一个zip压缩包,可以用第三方库或者解压后读取其中的XML;pdf则建议借助开源库解析。为了简化演示,下面以txt为主,读取后统一转成小写并按非字母字符切分。中文文档处理稍后单独说明。
using System.IO;
using System.Text.RegularExpressions;
public class DocumentLoader
{
// 读取目录下所有txt文件,返回文件名和内容的字典
public static Dictionary<string, string> LoadFromDirectory(string dirPath)
{
var docs = new Dictionary<string, string>();
foreach (var file in Directory.GetFiles(dirPath, "*.txt"))
{
string content = File.ReadAllText(file);
docs[Path.GetFileName(file)] = content;
}
return docs;
}
// 简易分词:英文按非字母字符切分,转小写
public static List<string> Tokenize(string text)
{
return Regex.Split(text.ToLower(), @"[^a-z]+")
.Where(t => t.Length > 2)
.ToList();
}
}
停用词过滤是预处理里容易被忽略但影响很大的一环。如果不把高频虚词过滤掉,任何两份文档的相似度都会虚高,聚类结果会挤成一团。可以自己维护一个停用词表,也可以使用现成的词库。中文文档则需要先分词,推荐使用结巴分词的.NET移植版,把连续的汉字切成词语后再进入后续流程,原理和英文完全一致。
三、TF-IDF特征提取与余弦相似度
分词完成后,接下来要回答“哪个词更能代表这份文档”。TF-IDF是最经典的答案:TF指词频,一个词在当前文档出现越多,权重越高;IDF是逆文档频率,一个词在越多文档中出现,说明它区分能力越弱,权重就要打折扣。两者相乘,就得到了既能体现文档内重要性、又能体现全局区分度的综合权重。
public class TfidfVectorizer
{
private List<string> vocabulary;
private Dictionary<string, double> idf;
public double[][] FitTransform(List<List<string>> documents)
{
// 统计每个词出现在多少份文档中
var docFreq = new Dictionary<string, int>();
foreach (var doc in documents)
foreach (var word in doc.Distinct())
docFreq[word] = docFreq.GetValueOrDefault(word) + 1;
vocabulary = docFreq.Keys.OrderBy(k => k).ToList();
int n = documents.Count;
idf = vocabulary.ToDictionary(
w => w,
w => Math.Log((double)(n + 1) / (docFreq[w] + 1)) + 1);
// 生成每份文档的向量
var vectors = new double[n][];
for (int i = 0; i < n; i++)
{
vectors[i] = new double[vocabulary.Count];
var counts = documents[i].GroupBy(w => w)
.ToDictionary(g => g.Key, g => g.Count());
int total = documents[i].Count;
foreach (var kv in counts)
{
int idx = vocabulary.IndexOf(kv.Key);
double tf = (double)kv.Value / total;
vectors[i][idx] = tf * idf[kv.Key];
}
}
return vectors;
}
}
有了向量之后,用余弦相似度来衡量两份文档的接近程度。余弦相似度计算两个向量夹角的余弦值,取值范围0到1,越接近1表示方向越一致,文档内容越相似。它的优点是对文档长度不敏感,长文档和短文档之间也能公平比较。
public static double CosineSimilarity(double[] a, double[] b)
{
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < a.Length; i++)
{
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
if (na == 0 || nb == 0) return 0;
return dot / (Math.Sqrt(na) * Math.Sqrt(nb));
}
四、用K-Means实现自动分组
特征和距离都准备好了,最后一步是聚类。K-Means是最容易上手的聚类算法:先随机指定K个中心点,把每份文档分配给离它最近的中心,然后重新计算每个簇的平均向量作为新中心,反复迭代直到分组不再变化。下面是完整的实现:
public class KMeans
{
public static int[] Cluster(double[][] data, int k, int maxIterations = 100, int seed = 42)
{
var rand = new Random(seed);
int n = data.Length, dim = data[0].Length;
var centers = new double[k][];
// 随机挑选k个样本作为初始中心
var picked = new HashSet<int>();
while (picked.Count < k)
picked.Add(rand.Next(n));
centers = picked.Select(i => (double[])data[i].Clone()).ToArray();
var labels = new int[n];
for (int iter = 0; iter < maxIterations; iter++)
{
bool changed = false;
// 分配阶段:每个样本归入最近的中心
for (int i = 0; i < n; i++)
{
int best = 0;
double bestDist = double.MaxValue;
for (int c = 0; c < k; c++)
{
double d = 1 - CosineSimilarity(data[i], centers[c]);
if (d < bestDist) { bestDist = d; best = c; }
}
if (labels[i] != best) { labels[i] = best; changed = true; }
}
// 更新阶段:重新计算簇中心
for (int c = 0; c < k; c++)
{
var mean = new double[dim];
int count = 0;
for (int i = 0; i < n; i++)
if (labels[i] == c) { count++; for (int d = 0; d < dim; d++) mean[d] += data[i][d]; }
if (count > 0 centers[c] = mean.Select(x => x / count).ToArray();
}
if (!changed) break;
}
return labels;
}
}
注意上面代码中有一处故意留待修正的写法:更新中心时需要补上if (count > 0)的完整判断逻辑,避免某个簇为空时除零。实际项目中建议加上空簇重新初始化的处理,防止聚类结果卡在劣质解上。
五、聚类结果评估与实用建议
聚类数量K的选择没有标准答案,常用做法是肘部法:让K从2逐步增大,计算每个K值下所有样本到所属中心的距离总和,画出曲线后找拐点,拐点处的K通常是较优选择。另一个指标是轮廓系数,它衡量每个样本与自己簇的紧密程度以及与相邻簇的分离程度,取值越接近1效果越好,可以在NuGet上找到现成的实现。
工程上还有几点经验值得参考。文档数量很大时,词表维度可能达到几万甚至几十万,建议只保留IDF权重最高的前几千个词,或者用稀疏矩阵存储向量以节省内存。如果想要更好的语义效果,可以尝试把TF-IDF换成词嵌入或者预训练语言模型输出的句向量,聚类流程本身不用改动。最后,聚类完成后给每个簇提取几个权重最高的关键词作为簇标签,能让人一眼看出这组文档大致讲了什么,整理归档时体验会好很多。