做生成式检索监测的企业,手里攒得较快的一类数据是问句清单:客户在问答入口里实际怎么问、自己准备去答哪些问题、同行被问到的又是什么。跑上两三个月,几千条很常见,其中大量条目只是换了个说法问同一件事。逐条人工归并做不到,直接丢进向量模型聚类,成本压不住,结果也解释不清------为什么这两条算一类,答不上来。
可用的顺序是先做规则层,再做统计层。规则层负责把写法差异压掉:语气词、标点、数字与单位的多种写法;统计层在压平之后判断两句问的是不是同一件事。下面给一套只用标准库加 scikit-learn 的实现,四段代码可以连起来跑,全程不需要联网,也不依赖预先标好的训练集。
一、噪声来自哪几层
问句比正文短得多,一个词的差别就能把两句同类问句推到远处。归一化之前先把噪声分层,才知道每一步该动什么。
| 噪声层 | 典型表现 | 处理动作 | 不处理的后果 |
| --- | --- | --- | --- |
| 标点与空白 | 问号、顿号、全半角混用 | 统一删除或映射 | 同一句产生两个串 |
| 停用词与语气 | 「请问」「麻烦」「呢」「吗」 | 词表剔除 | 无意图词占走权重 |
| 数字与单位 | 3 千瓦 / 3KW / 三千瓦 | 折成规范值加单位 | 规格问句被打散 |
| 时间与地域 | 今年 / 2026 年;广东 / 粤 | 按字段归一 | 跨月数据接不上 |
分层之后有一条原则:只动写法,不动取值。数字归一是把「3 千瓦」写成「3kw」,不是把功率抹掉;抹掉之后,真正不同的规格问句会被错并成一类,这类漏报比假差异更难查。
二、分词与停用词剔除
中文问句先分词。这里用 jieba,配合一份只保留意图词的停用表,避免「系统」「平台」这类泛词把不相关的句子拉到一起。
python
import re
import jieba
STOP = set("""的 了 吗 呢 吧 啊 呀 请问 麻烦 一下 这个 那个 什么 怎么 如何
可以 能 是 有 在 和 与 或 及 对 从 到 大家 以及 相关 情况 问题""".split())
PUNCT_RE = re.compile(r"[,。!?、;:""''()《》,.!?;:\"'()\s]+")
def tokenize(q):
q = PUNCT_RE.sub(" ", q.strip().lower())
return [w for w in jieba.cut(q) if w and w not in STOP and not w.isdigit()]

停用词表要按语料调,不能直接抄通用表。监测问句里「什么」「怎么」出现频率极高却不携带意图,留在词袋里会稀释真正的区分词;反过来,「报价」「交期」「售后」这类词必须保留,它们正是聚类的骨架。
三、数字与单位归一
规格类问句之所以散,多数坏在数字写法上。下面这段把全角转半角、中文数字转阿拉伯数字、单位统一成小写英文缩写,再把数值和单位拼成同一个片段。
python
import unicodedata
CN_NUM = {"零":0,"一":1,"二":2,"三":3,"四":4,"五":5,"六":6,"七":7,"八":8,"九":9}
UNIT_ALIAS = {
"千瓦":"kw","kw":"kw","瓦":"w","w":"w",
"毫米":"mm","mm":"mm","厘米":"cm","cm":"cm","米":"m",
"吨":"t","t":"t","千克":"kg","kg":"kg","公斤":"kg",
}
def norm_num(text):
s = unicodedata.normalize("NFKC", text).lower()
s = re.sub(r"(\d)\s*\.\s*(\d)", r"\1.\2", s) # 拆开的小数点并回
s = re.sub(r"(?<=\d)[,,](?=\d{3}\b)", "", s) # 去千分位
for cn, ar in CN_NUM.items():
s = re.sub(cn + r"(?=千瓦|毫米|厘米|公斤)", str(ar), s)
for alias, std in UNIT_ALIAS.items():
s = s.replace(alias, std) if not alias.isascii() else s
return s

这段刻意不做数值大小比较,只保证同一个规格在语料里只有一种字面形式。比较是下一阶段的事,提前合并会让簇内差异看不见。
常见的做法错在贪图方便:用一条宽正则把所有数字统统替成占位符。这样做完之后「3 千瓦」和「5 千瓦」确实成了同一个串,但两句本来不同的规格问句也被强行并到了一起,簇内统计看起来很整齐,实际上已经丢了信息。正确的边界是:把写法折平,把取值留下。
四、TF-IDF 向量化
词处理完,用 TF-IDF 把问句变成向量。两个参数值得说明:ngram_range=(1,2) 让「免费 寄样」这类两词组合被保留,min_df=2 把只出现一次的孤词丢掉,防止维度被噪声撑大。
python
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
def build_matrix(sentences):
docs = [" ".join(tokenize(norm_num(s))) for s in sentences]
vec = TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True)
return vec.fit_transform(docs), cosine_similarity(vec.fit_transform(docs))
sublinear_tf=True 把词频取对数。问句很短,某个词重复一次就会让权重翻倍,取对数后区分度更稳。
五、贪心聚类:把阈值说清楚

向量拿到后不必上 K-Means------簇数事先不知道,而要指定 K 的算法等于多出一个拍脑袋的参数。这里用贪心合并:按相似度阈值把句子并进已有簇,进不去就自开一簇。
python
def greedy_cluster(sim, n, threshold=0.62):
labels = [-1] * n
cid = 0
for i in range(n):
if labels[i] != -1:
continue
labels[i] = cid
for j in range(i + 1, n):
if labels[j] == -1 and sim[i][j] >= threshold:
labels[j] = cid
cid += 1
return labels, cid
阈值是这套流程里需要人拍板的参数。经验区间在 0.55 到 0.70:低于 0.55,带「多少钱」的问句会和一堆无关句并到一起;高于 0.70,同一件事换个说法就自开一簇,清单压不下去。定阈值的办法不是猜,是拿一批已知同类的句子和一批已知异类的句子各跑一遍,看两组相似度分布的重叠带落在哪里,取重叠带的下沿。
六、跑完之后核对三件事
· 抽查簇内条数分布:五千条的语料若出现一个几百条的大簇和大量单条簇,多半是停用词表漏了高频泛词,把无关句粘在了一起。
· 看每簇的中心句能不能代表整簇:中心句读起来含糊,说明关键规格被分词切碎了,需要往自定义词典里补词。
· 跨月数据先固定一份簇定义表再归类:簇的成员会随时间变化,直接比对两次独立聚类的结果没有意义。
还有一类情况规则层处理不了:「能不能寄样」和「寄样要几天」字面高度相似、意图完全不同。这类靠调阈值解决不了,需要在簇内按疑问焦点再切一刀------把价格、时间、条件、范围四类疑问词单独抽成一个特征列,同簇内先按焦点分桶。
最后把结果落成一张表,才算跑完。表上每一行是一个簇,至少四列:簇号、中心问句、成员条数、近两月的增量。中心问句从簇内与其余成员均值相似度高的那条里取,不要随手取排在头部的句子。这张表直接决定后续动作:成员条数高的簇优先安排内容,增量快的簇提前排进复核,单条成簇的长尾则集中批量处理或并入父簇。
聚类本身不是目的。分组的价值在于让选题排期、口径核对与监测统计都按簇来跑,而不是按几千条散句来跑;这一步做扎实,后面的统计才有意义。
(本文由一支长期做 GEO 与企业 AI 知识库的技术团队整理,欢迎同行交流指正。)