1. 为什么中文文本相似度这件事值得单独拿出来做
做搜索、推荐、去重、聚类、问答匹配的同学应该都有体会:英文场景下随便找个句向量模型,效果都不会太离谱,但一换到中文,问题就全冒出来了。分词边界模糊、同义词多、语序灵活、短文本信息密度低,这些坑一个比一个深。我最早做文本相似度的时候用的是 TF-IDF 加余弦,短句还行,长句一上来就崩,后来换 Word2Vec 做平均池化,效果提升有限,直到 BERT 类模型出来才算真正解决问题。
但 BERT 原生输出是 token 级别的,直接拿来算句子相似度需要自己做池化,而且不同层出来的向量效果差异很大,调起来很烦。GTE(General Text Embeddings)就是在这个背景下进入视野的——它把句子向量这件事做成了端到端的,输入一段文本,直接输出一个定长向量,拿来算余弦相似度就行,不用你再操心池化策略。
GTE 中文向量模型(网上也有人叫它 gte 阿里系列,因为最早是阿里团队开源出来的)目前在中文句向量任务上属于第一梯队的水平。它在 C-MTEB 中文评测基准上表现很稳,尤其是短文本匹配和语义检索这两个场景,实测下来比很多同量级的模型要扎实。这篇文章我打算把整个实战流程拆成 5 步,从环境准备到最终跑出一个可用的相似度计算脚本,每一步都讲清楚为什么这么做、坑在哪里。
适合谁看?如果你正在做以下任意一件事,这篇内容应该能直接抄作业:
- 要做中文语义搜索,需要把文档和 query 都转成向量
- 要做文本去重,判断两段话是不是一个意思
- 要做聚类或者分类,需要先拿到句子的稠密表示
- 单纯想搞清楚句向量模型到底怎么用、怎么选、怎么调
我下面用的方案是transformers直接加载 GTE 模型,不依赖额外的 sentence-transformers 封装,这样你能看清楚每一步到底发生了什么。如果你追求更省事的写法,后面我也会提 sentence-transformers 的替代方案。
2. 第一步:搞清楚 GTE 模型到底输出什么,别急着写代码
2.1 GTE 的模型结构和池化方式
很多人拿到模型就直接model.encode(),但根本不知道向量是怎么来的。GTE 的底座是 BERT 类的 Transformer 编码器,输入一段文本,经过多层 self-attention 之后,每个 token 都会得到一个隐藏状态向量。问题来了:一句话有 N 个 token,怎么变成一个向量?
GTE 用的是CLS 池化,也就是取第一个特殊 token[CLS]对应的输出向量作为整句的表示。这一点和 Sentence-BERT 常用的 mean pooling(对所有 token 向量求平均)不一样。为什么 GTE 选 CLS 池化?因为它在训练阶段就是按照 CLS 向量来做对比学习的,模型已经学会了把整句语义压缩到这一个位置上。你如果自己改成 mean pooling,反而会破坏它训练时学到的表示空间。
注意:不同模型的池化方式不一样,BGE 用的是 CLS,GTE 也是 CLS,但有些模型是 mean pooling 或者 last-token pooling。用错池化方式,相似度结果会明显变差,这是新手最容易踩的坑。
2.2 向量维度与相似度度量选择
GTE 中文模型常见的版本有gte-base-zh和gte-large-zh,base 版向量维度是 768,large 版是 1024。维度越高表达能力越强,但存储和计算成本也上去了。我一般做中小规模检索用 base 就够了,百万级以上的向量库再考虑 large。
相似度度量用余弦相似度,不是欧氏距离。原因是句向量模型训练时用的是对比学习,向量会被归一化到单位球面上,这时候余弦相似度和点积是等价的,而欧氏距离会受到向量模长的影响。所以标准做法是:先对向量做 L2 归一化,然后直接算点积,结果就是余弦相似度。
import numpy as np def cosine_sim(a, b): a = a / np.linalg.norm(a) b = b / np.linalg.norm(b) return float(np.dot(a, b))这段代码看着简单,但归一化这一步千万别省。我见过有人直接算点积,结果相似度分数超过 1,就是因为没归一化。
2.3 相似度分数的解读区间
余弦相似度的取值范围是 -1 到 1,但句向量模型实际输出基本落在 0.3 到 1 之间。根据我的实测经验,中文 GTE 模型的分数大致可以这样解读:
| 相似度区间 | 语义关系 | 典型场景 |
|---|---|---|
| 0.90 以上 | 几乎同义 | 复述、改写 |
| 0.75 - 0.90 | 高度相关 | 同一主题不同表述 |
| 0.55 - 0.75 | 中等相关 | 相关但不完全一致 |
| 0.35 - 0.55 | 弱相关 | 同一领域不同话题 |
| 0.35 以下 | 基本无关 | 不同领域 |
这个区间不是绝对的,跟你的语料领域有关。法律、医疗这种专业领域,分数普遍偏高,因为术语重合度高;日常口语场景分数会低一些。所以实际做业务的时候,阈值一定要在自己的验证集上重新标定,别直接套网上的数字。
3. 第二步:环境搭建与模型加载的实操细节
3.1 依赖安装与版本选择
环境这块我踩过最多的坑就是版本冲突。transformers和torch的版本要匹配,不然加载模型的时候会报各种奇怪的错。我目前稳定用的组合是:
pip install torch==2.1.0 pip install transformers==4.36.0 pip install numpy如果你有 GPU,torch 装对应的 CUDA 版本,比如torch==2.1.0+cu118。CPU 也能跑,就是慢,单条推理大概几百毫秒,批量处理会明显拖后腿。
提示:不要盲目追最新版 transformers,新版本有时候会改 tokenizer 的默认行为,导致同样的文本 tokenize 结果不一样,向量就变了。生产环境一定要锁版本。
3.2 模型下载与本地缓存
GTE 模型在 HuggingFace 上有官方仓库,直接按名字加载就行。但国内网络环境下载大模型经常断,我的做法是先下载到本地,然后从本地路径加载。
from transformers import AutoTokenizer, AutoModel model_name = "thenlper/gte-base-zh" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name)第一次运行会自动下载到~/.cache/huggingface/目录。如果下载失败,可以手动下载模型文件放到本地目录,然后把model_name换成你的本地路径。模型文件大概 400MB 左右(base 版),large 版接近 1.3GB。
3.3 设备选择与推理模式设置
加载完模型后有两件事必须做:一是把模型切到 eval 模式,二是根据有没有 GPU 决定设备。
import torch device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) model.eval()model.eval()会关掉 dropout 和 batch norm 的训练行为,保证每次推理结果一致。如果你忘了这一步,同样的文本两次算出来的向量会不一样,相似度自然也不稳定。这个坑我在早期项目里踩过,排查了半天才发现是忘了切 eval。
另外推理的时候一定要用torch.no_grad(),不然会构建计算图,显存直接爆掉。
4. 第三步:文本向量化的完整实现
4.1 编码函数的核心逻辑
把一段文本变成向量的过程分三步:tokenize、前向推理、池化。我把它封装成一个函数:
def encode(texts, tokenizer, model, device, max_length=512): inputs = tokenizer( texts, padding=True, truncation=True, max_length=max_length, return_tensors="pt" ).to(device) with torch.no_grad(): outputs = model(**inputs) # CLS 池化:取最后一个隐藏层第一个 token embeddings = outputs.last_hidden_state[:, 0] # L2 归一化 embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1) return embeddings.cpu().numpy()这里有几个关键点要展开说。
padding=True是批处理必须的,因为一个 batch 里句子长度不一样,要补齐到同一长度。truncation=True配合max_length是防止超长文本把显存撑爆,GTE 的最大长度是 512 个 token,超过的部分会被截断。中文一个字大概对应 1 到 1.5 个 token,所以 512 token 大概能覆盖 350 到 500 个汉字。
outputs.last_hidden_state[:, 0]就是取 CLS 位置的向量,[:, 0]表示所有样本的第 0 个 token。这一步是 GTE 的标准池化方式,别改成别的。
4.2 批处理大小怎么定
批处理大小直接影响吞吐和显存占用。我的经验值是这样的:
| 设备 | 模型 | 建议 batch size | 显存占用 |
|---|---|---|---|
| CPU | base | 8 - 16 | 内存约 2GB |
| GPU 8GB | base | 64 - 128 | 显存约 4GB |
| GPU 16GB | base | 128 - 256 | 显存约 8GB |
| GPU 16GB | large | 32 - 64 | 显存约 12GB |
这个不是死的,你可以从小的开始试,逐步往上加,直到显存快满为止。批处理太小的缺点是 GPU 利用率低,太大则容易 OOM。我一般先用 64 跑一批,看显存占用再调整。
4.3 长文本的处理策略
GTE 最大 512 token,超过就得截断。但直接截断会丢信息,尤其是文档类文本。我的处理策略分两种:
第一种是首尾截断,取前 256 token 加后 256 token,这样能保留开头的主旨和结尾的结论。第二种是分段编码再聚合,把长文本切成多个 512 token 的片段,分别编码,然后对所有片段向量求平均。第二种效果更好但计算量翻倍,适合对精度要求高的场景。
def encode_long_text(text, tokenizer, model, device, chunk_size=500): tokens = tokenizer.tokenize(text) chunks = [] for i in range(0, len(tokens), chunk_size): chunk = tokens[i:i + chunk_size] chunks.append(tokenizer.convert_tokens_to_string(chunk)) if not chunks: chunks = [text] vecs = encode(chunks, tokenizer, model, device) return vecs.mean(axis=0)这段代码先分词再切块,比按字符切更准确,因为不会把一个词切成两半。聚合用平均,也可以用加权平均(靠前的片段权重高),看你的业务需求。
5. 第四步:相似度计算与实战演示
5.1 一个完整的相似度计算脚本
把前面的东西串起来,写一个可以直接跑的脚本:
import numpy as np import torch from transformers import AutoTokenizer, AutoModel model_name = "thenlper/gte-base-zh" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) model.eval() def encode(texts, max_length=512): inputs = tokenizer(texts, padding=True, truncation=True, max_length=max_length, return_tensors="pt").to(device) with torch.no_grad(): outputs = model(**inputs) emb = outputs.last_hidden_state[:, 0] emb = torch.nn.functional.normalize(emb, p=2, dim=1) return emb.cpu().numpy() def similarity(text_a, text_b): vecs = encode([text_a, text_b]) return float(np.dot(vecs[0], vecs[1])) if __name__ == "__main__": pairs = [ ("今天天气真好", "今天阳光明媚"), ("今天天气真好", "我喜欢吃苹果"), ("如何学习编程", "编程入门的方法"), ("如何学习编程", "今天股市大涨"), ] for a, b in pairs: score = similarity(a, b) print(f"{score:.4f} | {a} vs {b}")跑出来的结果大概是这样:
0.9231 | 今天天气真好 vs 今天阳光明媚 0.4127 | 今天天气真好 vs 我喜欢吃苹果 0.8876 | 如何学习编程 vs 编程入门的方法 0.3215 | 如何学习编程 vs 今天股市大涨可以看到同义句的分数在 0.9 左右,无关句在 0.3 到 0.4 之间,区分度很明显。这就是句向量模型的价值——它把语义相似度变成了一个可以直接比较的数字。
5.2 批量相似度矩阵计算
实际业务里经常要算一个 query 和一堆候选的相似度,这时候用矩阵运算比循环快得多:
def batch_similarity(query, candidates): all_texts = [query] + candidates vecs = encode(all_texts) query_vec = vecs[0] candidate_vecs = vecs[1:] scores = np.dot(candidate_vecs, query_vec) return scores因为向量已经归一化了,直接点积就是余弦相似度。这个操作是纯矩阵乘法,速度极快,一万条候选也就几毫秒。
5.3 语义检索的完整流程
把相似度计算扩展一下就是一个简易的语义检索系统:
corpus = [ "深度学习模型的训练需要大量数据", "今天中午吃什么比较好", "Transformer 是自然语言处理的基础架构", "周末去爬山要注意安全", "预训练语言模型在多项任务上取得突破", ] query = "神经网络是怎么工作的" scores = batch_similarity(query, corpus) ranked = sorted(zip(corpus, scores), key=lambda x: -x[1]) for text, score in ranked: print(f"{score:.4f} {text}")输出会按相似度从高到低排列,排在前面的就是语义上最接近 query 的句子。这就是语义检索的核心逻辑,实际系统里只是把 corpus 换成向量数据库,把暴力搜索换成近似最近邻搜索而已。
6. 第五步:性能优化与常见问题排查
6.1 推理加速的几个实用手段
模型跑起来之后,下一步就是让它跑得快。我常用的加速手段有三个:
第一个是 ONNX 导出。把 PyTorch 模型转成 ONNX 格式,推理速度能提升 30% 到 50%,尤其是在 CPU 上效果明显。用optimum库可以一键导出:
pip install optimum[onnxruntime] optimum-cli export onnx --model thenlper/gte-base-zh gte_onnx/第二个是半精度推理。GPU 上把模型转成 fp16,显存占用减半,速度提升 20% 左右,精度损失很小:
model = model.half()第三个是批处理调优。前面说过 batch size 要试,但还有一个细节是padding策略。默认的padding=True会补齐到 batch 内最长句子的长度,如果 batch 里有一句特别长,其他短句就浪费了很多计算。可以按长度排序后再分批,让每个 batch 内的长度尽量接近。
6.2 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 相似度分数普遍偏高 | 未归一化 / 池化方式错误 | 检查是否 L2 归一化,确认用 CLS 池化 |
| 相同文本两次结果不同 | 忘了 model.eval() | 推理前调用 model.eval() |
| 显存溢出 | batch size 太大 / 未用 no_grad | 减小 batch,加 torch.no_grad() |
| 长文本效果差 | 被截断丢失信息 | 用分段编码再聚合 |
| 加载模型报错 | transformers 版本不匹配 | 锁定版本,参考官方推荐组合 |
| 中文 tokenize 异常 | tokenizer 加载错误 | 确认用的是对应模型的 tokenizer |
6.3 我踩过的几个真实坑
第一个坑是混用了不同模型的 tokenizer。有一次我图省事,用了一个模型的 tokenizer 去处理另一个模型的输入,结果向量完全不对,相似度全是乱的。tokenizer 和模型必须成对使用,这个没有例外。
第二个坑是忽略了文本预处理。中文文本里经常有全角半角混用、多余空格、特殊符号,这些都会影响 tokenize 结果。我现在的做法是在编码前统一做一遍清洗:全角转半角、去除多余空白、过滤控制字符。这一步看着不起眼,但对相似度稳定性影响很大。
第三个坑是阈值直接套用。我最早做去重的时候,直接用了网上说的 0.85 阈值,结果误杀了一大批。后来在自己的数据上标了 500 对样本,重新画了 ROC 曲线,发现最优阈值其实是 0.78。阈值这件事,一定要用自己的数据标定,没有通用答案。
6.4 用 sentence-transformers 的省事写法
如果你不想自己写池化逻辑,可以用sentence-transformers封装,代码会简洁很多:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("thenlper/gte-base-zh") embeddings = model.encode(["今天天气真好", "今天阳光明媚"]) similarity = model.similarity(embeddings[0], embeddings[1])它内部会自动处理池化和归一化,用起来省心。但代价是你对细节的控制变弱了,比如想换池化方式或者自定义 max_length 就没那么灵活。我的建议是:快速验证用 sentence-transformers,生产环境还是自己写,可控性更强。
7. 关于模型选型和后续扩展的一些个人体会
GTE 中文模型不是唯一的选择,同量级还有 BGE、M3E、text2vec 这些。我实际对比过几个,在通用中文场景下 GTE 和 BGE 的差距很小,都在 1 到 2 个百分点以内。选哪个更多看你的具体任务:检索任务 BGE 的指令前缀机制有时候更有优势,纯相似度计算 GTE 更直接。我的做法是拿自己的验证集各跑一遍,谁高用谁,别迷信榜单。
后续如果要往生产环境走,还有几件事要做:一是把向量存进向量数据库(Milvus、Faiss、Qdrant 都行),二是做索引加速,三是加一层重排序(用 cross-encoder 对 top-k 结果精排)。这套组合下来,检索质量会比单纯用句向量高一大截。
最后分享一个小技巧:如果你的业务文本有很强的领域特性(比如全是法律条文或者医疗记录),可以考虑在 GTE 基础上做领域微调。用对比学习的方式,构造正负样本对,跑几个 epoch,效果提升通常比换更大的模型还明显。微调的数据量不用很大,几千对就能看到效果。