GTE中文向量模型实战:5步搞定文本相似度计算
2026/9/24 20:12:43 网站建设 项目流程

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-zhgte-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 依赖安装与版本选择

环境这块我踩过最多的坑就是版本冲突。transformerstorch的版本要匹配,不然加载模型的时候会报各种奇怪的错。我目前稳定用的组合是:

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显存占用
CPUbase8 - 16内存约 2GB
GPU 8GBbase64 - 128显存约 4GB
GPU 16GBbase128 - 256显存约 8GB
GPU 16GBlarge32 - 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,效果提升通常比换更大的模型还明显。微调的数据量不用很大,几千对就能看到效果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询