上千条客户问句摊在表格里,看着每条都不一样,去掉说法差异之后往往只剩一百多个意思。「质保期多久」「这个保修几年」「出问题能免费换吗」说的是同一件事。知识库需要的不是原始问句,而是意图:一个意图配一条标准问、一条答案、一组别名说法。原始问句直接入库,检索时同一条答复会被三种表述各自命中,答案还不一定一致。
下面这套流程不依赖大模型:字符 n-gram 加 TF-IDF 表示问句,层次聚类按距离阈值切簇,再用规则从簇里挑标准问,落成带字段的条目。五步里每一步都说清楚它容易在哪里失效。
一、清洗与归一:先把「问的是什么」剥出来
问句前面挂着寒暄,后面挂着标点,中间还可能夹着指代。清洗只针对聚类用的那份文本,原文一份都不能改------条目要能回溯到客户真实说过什么。
python
import re
NOISE = re.compile(r"^(你好[,,]?|在吗[,,]?|请问|麻烦问一下?|师傅[,,]?)+")
TAIL = re.compile(r"[??。!!,,、~\s]+$")
def normalize(raw):
"""只用于聚类;原文另存一份,别在这一步改写。"""
t = NOISE.sub("", raw.strip())
t = re.sub(r"\s+", "", t)
return TAIL.sub("", t).lower()
def split_short(lines, min_len=5):
"""过短的问句不带意图信息,单独归桶,不要混进聚类。"""
keep = [l for l in lines if len(normalize(l)) >= min_len]
brief = [l for l in lines if 0 < len(normalize(l)) < min_len]
return keep, brief
「多少钱」「在吗」这类短句归进「待补上下文」桶。它们不带主题信息,混进聚类会把不同话题的句子拉到一起,事后拆簇比重新聚一遍还麻烦。

二、向量化:中文短问句用字符 n-gram 就够
分词不是必需项。问句通常十几个字,错别字与口语省略多,切词一旦出错就把整句推到别的方向上;2 到 4 字符的 n-gram 稳定得多。
python
from sklearn.feature_extraction.text import TfidfVectorizer
def vectorize(questions):
"""sublinear_tf 压高频词,min_df 压一次性写法。"""
vec = TfidfVectorizer(analyzer="char", ngram_range=(2, 4),
sublinear_tf=True, min_df=2, max_features=20000)
return vec.fit_transform(questions)
min_df 是这一步里需要反复调的参数。设成 1,只出现一次的写法会自成方向,簇数直接翻几倍;设太大,低频但真实存在的说法被压成零向量,全部落进噪声簇。做法是从 2 起步,观察簇数随 min_df 的变化曲线,找到拐点再定。
三、聚类与阈值:簇数是结果,不是目标
层次聚类的好处在于阈值语义直观------两点之间的距离表示「两个问句说得有多不像」,不必事先猜要分多少个簇。
python
from scipy.spatial.distance import pdist, squareform
from sklearn.cluster import AgglomerativeClustering
def cluster(matrix, threshold=0.62):
"""n_clusters=None:让阈值决定簇数,簇数不是事先定的指标。"""
dist = squareform(pdist(matrix.toarray(), metric="cosine"))
model = AgglomerativeClustering(n_clusters=None, metric="precomputed",
linkage="average", distance_threshold=threshold)
return model.fit_predict(dist)
阈值往上调,簇变少,不同意思被合并的风险上升;往下调,簇变多,同一个意思被切成好几份。判断标准不是簇数好不好看,而是随机抽十个簇,看其中有几个能共用同一条答案------这个比例可以量化复核,比盯着聚类图可靠。

四、簇的体检:异常形态各有成因
| 现象 | 大概率原因 | 处置 |
| --- | --- | --- |
| 簇数上千,多数簇只有一条 | 阈值过低,或清洗没做够 | 先查归一,再逐步上调阈值 |
| 一个大簇吃掉三成问句 | 高频虚词权重压不住 | 提高 n-gram 下限,或换 complete 联动 |
| 同主题被切成三簇 | 问句里掺了型号、地名 | 聚类前把可变实体换成占位符 |
| 簇内句子通、答案对不上 | 同形不同义 | 拆成两条条目,不强行合并 |
| 新说法全部落进噪声簇 | 沿用了半年前的词表 | 定期重跑词表,而不是只重跑聚类 |
实体占位这一步很容易被低估:「某型号能不能延保」和「另一型号能不能延保」在字符层面差别不小,在意图层面是同一句。聚类前把型号、地区、日期换成占位标记,簇的质量会有明显变化。
五、从簇到条目:标准问挑的不是字数多的那句
一个簇给一条条目。标准问既不是出现次数多的那句,也不是写得完整的那句,而是脱离上下文仍然读得懂的那句。
python
VAGUE = re.compile(r"(这个|那个|它|上面|如前|你们)")
def pick_std(members):
"""members: [(原文, 归一句, 出现次数)]。含指代的降权,但不删除。"""
scored = []
for raw, norm, count in members:
weight = 0.4 if VAGUE.search(norm) else 1.0
scored.append((count * weight + min(len(norm), 24) * 0.05, raw, norm))
scored.sort(reverse=True)
return scored[0][1], [r for _, r, _ in scored[:6]]
降权而不是丢弃,是因为客户原话很值钱:检索命中靠的就是这些说法。挑完标准问,剩下的工作是把条目落成字段,一个簇一行。
| 字段 | 内容 | 约束 |
| --- | --- | --- |
| standard_q | 挑出的标准问 | 脱离上下文可读 |
| aliases | 簇内原文若干条 | 保留口语与错别字 |
| answer | 一条成文答复 | 不许写「视情况而定」 |
| applies_to | 型号、地区、时间窗口 | 无条件时明确写「通用」 |
| source_window | 会话日期区间 | 超过半年未复核的标记待核 |
applies_to 单独成列看着是小事,实际决定答案能不能被直接用:答复写「质保十二个月」而不带条件,检索命中之后还得再问一句是哪个型号,问答就退回人工。

条目字段确定之后,写出来的结构是一段可以直接进库的记录,答案留空位由人工补。
python
def make_entry(cid, members, answers, window):
std, aliases = pick_std(members)
return {
"entry_id": "Q%04d" % cid,
"standard_q": std,
"aliases": aliases, # 保留口语与错别字,命中靠它们
"answer": answers.get(std, "待补:本簇没有可用答案"),
"applies_to": answers.get(std + "#scope", "通用"),
"source_window": window, # 会话日期区间,便于日后复核
"status": "draft", # draft / reviewed / retired
}
六、评估与回流:簇会自己漂
· 纯度抽检:随机抽二十个簇,人工判「能不能共用一条答案」,共用率低于八成说明阈值需要往下调。
· 覆盖度:没被聚进任何簇的单条问句占比,超过三分之一要回头查清洗,而不是先动聚类参数。
· 新增复检:每周把新问句投进已有簇,统计归错率。归错率突然上升,通常是新业务带来了一批新语义。
· 淘汰:一条答复三个月没被命中,不直接删,先降到兜底位置再观察一轮。
这套流程里没有任何一步需要训练模型:清洗、向量化、聚类、挑标准问、落字段,每步都能单独复核。它能解决的问题也有限------只保证同义说法被归到一起,不保证归进去的那条答案是对的。答案对不对要人写完再核对,聚类的意义是把「该核对哪几条」从一千句压到一百句,剩下那九十句留给下一轮。
(本文由一支长期做企业信息结构化与检索侧观察的技术团队整理,欢迎同行交流指正。)