中文短文本相似度实测:字符 n-gram 的 n 该取几?

简介: 做 GEO 内容比对时,字符 n-gram 是最省事的相似度算法,但 n 取 1、2、3 差别极大。本文用 28 对短文本实测 Jaccard、Dice、TF-IDF 三种打分,结论与常见建议相反:n=3 时有 4 对同义改写被判 0 分。

一、问题从哪来

前段时间做 GEO 相关的内容可见性监测,需要批量比对一批 AI 回答,遇到一个当时没想通的现象。

两句话,人读起来意思基本一致:

A: HTTP 状态码 429 代表请求过多,服务端做了限流,建议退避后重试。
B: HTTP 429 表示请求过于频繁,服务器已限流,应等待一段时间再重试。

用字符 3-gram 的 Jaccard 系数算,得分 0.0755。

一开始我以为是模型输出不稳定,换了几个采样角度重跑,分数依旧这个量级。后来才反应过来:问题不在模型,在度量本身。是这把尺子量不了这个长度。

于是把这类指标单独拎出来测一遍——不掺模型、不涉及任何外部服务,纯本地跑,把 n 从 1 调到 3,看这把尺子到底在哪一档准。


二、同一套表示,三种打分

三种指标共用一套文本表示:先归一化(转小写、去掉标点和空白),再切成字符 n-gram。区别只在怎么打分。

Jaccard:两个 n-gram 集合的交集除以并集。只看“有没有”,不看“出现几次”。

J(A, B) = |A ∩ B| / |A ∪ B|

Dice:同样是集合运算,但分母是两个集合大小之和,比并集小,所以对短文本更宽松。

D(A, B) = 2|A ∩ B| / (|A| + |B|)

TF-IDF 余弦:把 n-gram 当词项,用文内频次做 TF,用全部语料算 IDF,再求向量夹角余弦。它同时看“有没有”“多不多”“稀不稀有”。

三者里,Jaccard 和 Dice 是纯集合运算,TF-IDF 引入了频次和全局权重。
01_three_metrics.png


三、代码

纯标准库,不需要 numpy,也不需要分词器。

import math, re
from collections import Counter

def norm(text):
    """转小写,去掉标点与空白,只保留中英文和数字"""
    return re.sub(r"[^\u4e00-\u9fffa-z0-9]+", "", text.lower())

def ngram_list(text, n):
    """返回字符 n-gram 列表,保留重复(TF 需要频次)"""
    s = norm(text)
    if len(s) < n:
        return [s] if s else []
    return [s[i:i + n] for i in range(len(s) - n + 1)]

def jaccard(a, b, n):
    A, B = set(ngram_list(a, n)), set(ngram_list(b, n))
    if not A or not B:
        return 0.0
    return len(A & B) / len(A | B)

def dice(a, b, n):
    A, B = set(ngram_list(a, n)), set(ngram_list(b, n))
    if not A or not B:
        return 0.0
    return 2 * len(A & B) / (len(A) + len(B))

def build_idf(pairs, n):
    """用全部句子作语料计算 IDF"""
    docs = []
    for _, a, b in pairs:
        docs.append(set(ngram_list(a, n)))
        docs.append(set(ngram_list(b, n)))
    df = Counter()
    for d in docs:
        df.update(d)
    total = len(docs)
    return {
   t: math.log((1 + total) / (1 + c)) + 1.0 for t, c in df.items()}

def cosine(a, b, n, idf):
    def vec(text):
        grams = ngram_list(text, n)
        if not grams:
            return {
   }
        tf = Counter(grams)
        total = len(grams)
        return {
   t: (c / total) * idf.get(t, 1.0) for t, c in tf.items()}

    va, vb = vec(a), vec(b)
    if not va or not vb:
        return 0.0
    dot = sum(v * vb.get(k, 0.0) for k, v in va.items())
    na = math.sqrt(sum(v * v for v in va.values()))
    nb = math.sqrt(sum(v * v for v in vb.values()))
    return dot / (na * nb) if na and nb else 0.0

三个函数都不长,关键在于先把归一化规则定死。全角半角、英文大小写、标点处理方式只要不一致,后面所有分数都不可比。

运行方式

不需要安装任何依赖,标准库直接跑:

python sim_metrics.py

数据集就是一个三元组列表,第一项是分组标签,后两项是待比较的文本:

PAIRS = [
    ("same", "服务器返回 429 表示请求过多", "HTTP 状态码 429 意味着客户端发送请求过于频繁"),
    ("diff", "服务器的限流策略需要按业务调整", "他昨天在超市买了两斤苹果"),
    ("copy", "深圳夏季的湿度常年偏高", "深圳夏季的湿度常年偏高"),
]

脚本会按 n=1、2、3 各算一遍三种指标,输出汇总表。实际运行的开头几行是:

文本对总数: 28

| n | 指标 | copy | same | partial | diff | 区分度 | 可分性 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | Jaccard | 1.0000 | 0.4032 | 0.1581 | 0.0204 | 0.3828 | 0.1102 |
| 1 | Dice | 1.0000 | 0.5514 | 0.2701 | 0.0384 | 0.5130 | 0.1705 |

copy 组一列全部为 1.0000,这是实现正确性的自检——如果这里有任何一个不是 1,说明归一化或切分逻辑写错了,后面的数据都不用看。


四、数据集与判据

数据集由我手工构造,共 28 对中文短文本,按人读起来的语义关系分四组:

组 对数 含义 期望
copy 4 完全相同的副本 所有指标应为 1.0,用来验证实现没写错
same 10 同义改写,说法不同意思相同 应高分
partial 6 同一话题,但侧重不同 应中分
diff 8 完全无关 应低分

判据不看单个分数,看两个派生量:

区分度 = mean(same) - mean(diff)。它衡量指标能不能把“意思相同”和“意思无关”拉开。

可分性 = min(same) - max(diff)。这是更硬的条件:只有大于 0,才存在一个固定阈值把两类完全分开。小于等于 0 意味着无论阈值定在哪,都必然误判其中一类。


五、实测结果

28 对,n 分别取 1、2、3,三种打分方式,共 9 组结果:

n 指标 copy same partial diff 区分度 可分性
1 Jaccard 1.0000 0.4032 0.1581 0.0204 0.3828 0.1102
1 Dice 1.0000 0.5514 0.2701 0.0384 0.5130 0.1705
1 TF-IDF 余弦 1.0000 0.5361 0.2341 0.0241 0.5120 0.2000
2 Jaccard 1.0000 0.1693 0.0904 0.0000 0.1693 0.0476
2 Dice 1.0000 0.2712 0.1635 0.0000 0.2712 0.0909
2 TF-IDF 余弦 1.0000 0.2401 0.1281 0.0000 0.2401 0.0779
3 Jaccard 1.0000 0.0761 0.0391 0.0000 0.0761 0.0000
3 Dice 1.0000 0.1296 0.0717 0.0000 0.1296 0.0000
3 TF-IDF 余弦 1.0000 0.1137 0.0553 0.0000 0.1137 0.0000

copy 组全部 1.0000,说明实现没问题。真正值得看的是最后三行的可分性。
02_divergence.png


六、四条结论

1. n 越大不是越严格,是越失真

n=3 时,三种指标的可分性全部归零。原因是 same 组里出现了 0.0000:

10 对同义改写,有 4 对得分为 0,与无关文本完全无法区分。

其中一对是:

A: 深圳的夏天又闷又热
B: 深圳夏季天气闷热

B 句可以说是 A 句的压缩版本,人读起来毫无歧义。但在字符 3-gram 下,“深圳的”“夏天又”“天又闷”与“深圳夏”“圳夏季”“季天气”没有一个连续三字重合,交集为空。

结论:在短句场景下把 n 从 1 加到 3,不是提高了严格性,而是把有效信号一起删掉了。03_counterexample.png

2. n=1 反而是最优档

n=1 时 Jaccard 可分性 0.1102、Dice 0.1705、TF-IDF 0.2000,三者都远高于 n=2 和 n=3。

原因不复杂:短文本里凑齐 3 个连续相同字符的概率太低。单字虽然携带的语义信息少,但重合概率高,反而形成了稳定信号。

这一点和“中文 n-gram 一般取 2 或 3”的常见建议相反。那个建议通常来自长文本场景,直接搬到短句上就失效了。

3. 同一 n 下,Dice 恒定优于 Jaccard

n Jaccard 区分度 Dice 区分度 提升
1 0.3828 0.5130 +34%
2 0.1693 0.2712 +60%
3 0.0761 0.1296 +70%

三档全部 Dice 更高。原因是 Dice 的分母是两边集合大小之和,比并集小,在交集相同的情况下天然给出更高的分数,对短文本更友好。

如果只改一处,就把 Jaccard 换成 Dice,成本为零。

4. TF-IDF 余弦只在 n=1 时才最好

n=1 时它的可分性 0.2000,是九组里最高的。但 n=2 掉到 0.0779,低于 Dice 的 0.0909;n=3 同样低于 Dice。

原因在 IDF:短文本里每个 n-gram 都接近只出现一次,IDF 权重拉不开差距,反而把频次噪声放大了。n=1 时常用字多、DF 高、IDF 低,恰好起到了抑制高频字的平滑作用。


七、文本变长会好转,但排序不变

上面的数据都是 20 字以内的短句。补一组 40 字左右的段落做对照:

n 同主题段落得分 无关段落得分 区分度
1 0.3651 0.0441 0.3210
2 0.2647 0.0000 0.2647
3 0.1667 0.0000 0.1667

文本变长后 n=3 从完全失效恢复到了 0.1667,但仍低于 n=1 的 0.3210。排序没变。

所以 n 的选择要跟文本长度挂钩:短句用 1,长段落用 1 到 2,n=3 只在“文本够长且结构高度同构”的场景才有意义。


八、这套实验测不到什么

数据集规模小。 28 对是手工构造的,够看出方向,不够做统计显著性判断。要下强结论得上千对。

分组带主观性。 same 和 partial 的界限由我判断,换个人标注会有出入。

全部指标都停在字面层面。 同义改写得分天然偏低是这类指标的固有特征,不是实现 bug。这也是第一节那个 0.0755 的来源——它没有错,只是被用在了不合适的长度上。

不测语义。 真要判断两段话意思是否等价,得上 embedding 余弦。但 embedding 有自己的问题:向量相似度高,不代表论证结构相似。


九、下一步

值得做的下一个对照,是把 embedding 余弦加进这张表做三方对比。

重点看一类样本:字面分低但语义分高的那些。它们代表模型在换说法而不是换结论,而这批样本恰好是做内容优化时最该研究的对象——它揭示了同一个事实可以被组织成多少种不同的表述。

这部分涉及向量化和阈值标定,比本篇复杂,放到下一篇。向量表示我打算用百炼的文本嵌入接口来做,算完之后把字面指标和语义指标放在同一张表上对照,看它们在哪类样本上分歧最大。


作者:GEO优化阿文
用 Python 做生成式引擎(GEO)检索实测,十二年实体门店运营经验。

相关文章
|
14天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
5911 44
|
14天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
5011 58
|
8天前
|
Linux 开发工具 iOS开发
2026最新codex下载安装保姆级使用教程(下载+安装+登录+使用,含所有历史版本)
本文详解OpenAI Codex国内快速上手指南:提供Win/macOS/Linux全平台免安装包、npm/Homebrew等多方式安装、账号/API Key双登录、简体中文切换,覆盖下载、安装、汉化、使用及高频避坑(如微软商店失败、npm超时等),10分钟即可跑通。
2356 6
|
6天前
|
人工智能 安全 API
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(dsh)是DeepSeek AI于2026年开源的轻量级Agent运行框架,基于Cordis内核,践行“一切皆插件”理念。支持Web/桌面双端,开箱即用;内置多模型接入、丰富插件生态(超3.5万),适配编程、办公、创作等场景。(239字)
1469 0
|
9天前
|
云安全 人工智能 安全
|
2天前
|
云安全 人工智能 安全
|
8天前
|
人工智能 并行计算 调度
本地 AI 一键部署工具 LocModel:Windows 端集成画图、视频生成、Agent 的开源模型运行方案
LocModel 是专为 Windows 设计的一站式本地 AI 工具,支持零配置启动、自动硬件检测、GGUF 模型一键加载、文生图/视频生成及 Agent 自动化任务。适配 8G 显存,内置显存分片调度,轻松实现 AI 漫剧批量创作与离线大模型调用。(239 字)