简介:面向中文自然语言处理与数据增强实践场景,压缩包内提供一套基于faiss索引与chinese simbert向量化的最近邻中文label数据增强实现,适合需要扩充带标签小样本数据集的算法工程师与NLP学习者。资源共6个文件,包括3个csv数据文件、1个yaml配置、1个faiss索引文件及1个Python主脚本,压缩包整体约55.06MB,结构紧凑便于直接对照运行。核心流程会在无标签数据集上构建faiss索引,再为每条带标签文本检索最相似的无标签句子,输出sim_text、sim_value、label三元组到扩充后的csv文件,实现低成本数据扩充。工程依赖Python3.7、bert4keras0.11.3、faiss-gpu1.7.2与yaml6.0,并附有mkl-service安装提示,可快速搭建同款环境。目前已有197人学习浏览,适合作为中文相似文本检索与半监督数据增强的训练参考。
1. 用SimBERT向量化加FAISS最近邻做中文label数据增强:先搞清楚它在解决什么
做中文NLP分类任务时,最头疼的往往不是模型结构,而是标注数据不够。所谓数据增强,在这套方案里指的是利用faiss索引和Chinese SimBERT向量化,给每一条中文label数据找到语义最接近的几条邻居,再把邻居文本连同原标签一起并入训练集。它能解决一个很具体的痛点:标注量卡在几千条,模型换了两三个仍然过拟合,但你又没有预算和人力去标更多数据。这个方向适合手里已经有一批干净的中文文本分类数据、熟悉Python和基本NLP流程,并且愿意静下心调一遍相似度阈值的人。
思路听上去不复杂,但落地时牵涉到模型选型、向量索引、阈值设定、去重和验证策略。任何一个环节草率,增强出来的数据不仅没用,还会污染原有训练集。下面我把整条链路拆开讲,从原理到最小可跑代码,把参数怎么定、坑在哪里一次说清楚。
2. 先把原理踩实:SimBERT向量、FAISS索引与最近邻增强为什么能成
2.1 SimBERT为什么适合做中文句子向量
做向量化最近邻增强,前提是相似文本在向量空间里真的彼此靠近。原生BERT的CLS向量经常被批评为各向异性,不同领域的句子向量会挤在一个狭窄的锥形空间里,直接拿它计算最近邻,排在前面的往往不是语义相似,而是表层字面重合。SimBERT在预训练阶段使用了对偶对比学习,把语义相同的句子拉近、语义不同的句子推远,这让它的向量空间在中文短文本上比原生BERT更均匀、更有区分度。
这里要强调的是,SimBERT最初的目标是短文本相似度计算,不是通用句子嵌入。它的特点是能抓住"改个说法但意思不变"的同类表达,而这正好是数据增强需要的候选。对比回译增强,SimBERT向量近邻不需要额外依赖机器翻译接口,也不会产生大量语法怪异的长句;对比同义词替换,它不局限于词典,能识别跨表达的意译关系。所以在这套项目实践里,SimBERT承担的是"语义相似度判别器"的角色,而不是常规的分类特征提取器。
选型时还要注意一个容易踩的认知误区:SimBERT只有一个中文领域版本,英文场景并不适用。如果你的数据是混合语言,要先把中文文本过滤出来,或者再去寻找英文对应的SimBERT权重。向量维度通常在768,计算方式和BERT一致,这决定了后续FAISS的索引参数不需要额外适配。
2.2 FAISS在这里的角色:不是检索,是给每个样本找邻居
FAISS在工业界最常被用作向量召回引擎,从千万级向量库里快速捞出与查询向量相似的topK。但在当前场景下,我们不关心"用户来了要搜什么",而是关心"训练集里每一条文本,它和其他文本的语义距离是多少"。所以FAISS的角色更像是一个并行的最近邻计算器,利用索引结构和底层指令集,把暴力搜索的耗时压缩到可接受范围。
样本量在五万以内,直接用numpy算全量内积矩阵也能跑,内存开销大约是N * N * 4字节,五万条就是10GB,这还不算中间拷贝。把向量交给FAISS的IndexFlatIP,内存同样会占用那么多,但搜索时FAISS会帮你分块处理,并且查询整个索引返回所有近邻的速度会比两层for循环快两个数量级以上。当样本量到百万级,还需要考虑IVF这类倒排索引,它是用聚类把向量空间划分成若干区域,查询时只扫候选区域,牺牲少量精度换取速度。
在这个项目里,FAISS还有一个隐藏优势:支持向量按ID管理。每条文本的向量可以从1开始编号,查询结果返回的是整数下标,直接对应到DataFrame的行。这样就不需要手工维护一份“向量到文本”的字典,避免后续去重和合并时出现下标错位。我们在编码时保持顺序和原始DataFrame一致,查询结果里的indices矩阵就能直接当成样本编号用。
2.3 最近邻增强的数据流:从一条标注怎么变成一组标注
把原理落实到操作层,整个流程可以归纳成四步:
- 第一步,用SimBERT把每一条标注文本转成高维向量,并做L2归一化;
- 第二步,把全部向量写入FAISS索引;
- 第三步,对索引中每一条向量查询topK近邻,排除自身;
- 第四步,把符合条件的邻居文本与标签组合成新样本,经过滤去重后合并进训练集。
这里的核心决策是:邻居文本的标签从哪里来。如果邻居本身就是有标注的,最稳妥的做法是直接采用邻居自己的标签,因为它是人工标注的结果,可信度高。如果邻居是外部无标注语料,那只能把当前样本的标签复制给邻居,相当于打一条伪标签。伪标签的质量完全取决于SimBERT的相似度是否可靠,所以阈值必须比前者更严格。
我一般优先使用第一种来源,也就是只在原始标注集内部寻找邻居。这种方式不会引入新的文本类型,但能有效增加同一语义表达的多样性,让模型在保持类别区分能力的同时,对同义词和语序变化更鲁棒。只有当原始数据实在太少、内部邻居都是重复句子时,才考虑引入外部语料。无论哪种来源,相似度阈值都不能省,否则“苹果好吃”和“苹果手机好用”这种语义确实接近但标签不同的噪声样本就会被当成增强样本吸收进来。
3. 动手做:用SimBERT向量化到FAISS最近邻增强的最小可跑流程
3.1 环境准备与模型加载:少不了的几个包和硬件要求
先说依赖项:Python 3.8以上,需要安装faiss-cpu或faiss-gpu、pandas、numpy、torch以及sentence-transformers。如果只想跑通流程,faiss-cpu足够,几万条文本的建索引和查询都在秒级。样本量超过百万时再考虑GPU版,而且GPU版在调用多块卡时需要显式指定资源,并不是装上就自动加速。
模型加载我推荐通过sentence-transformers接口,因为它内部封装了tokenize、池化、归一化等步骤,能让我们把精力集中在FAISS参数上。SimBERT原始权重是TensorFlow格式,需要先转换成sentence-transformers能加载的目录结构。转换方式一般是先下载官方TF权重,再利用transformers的转换脚本生成PyTorch权重,最后用sentence-transformers的Snapshots目录组织起来。如果不想折腾,本地临时用其他中文句向量模型占位也可以,但线上复现时仍然推荐换回SimBERT。
from sentence_transformers import SentenceTransformer # 改成你本地转换好的 chinese-simbert 目录 MODEL_PATH = "/data/models/simbert-st" model = SentenceTransformer(MODEL_PATH) def embed_texts(texts, batch_size=64): """ 批量向量化中文文本。 返回经过 L2 归一化的 float32 向量,shape=(N, emb_dim)。 """ emb = model.encode( texts, batch_size=batch_size, show_progress_bar=True, convert_to_numpy=True, normalize_embeddings=True, # sentence-transformers 直接做 L2 归一化 ) return emb.astype(np.float32)这段代码里最关键的是normalize_embeddings=True,它保证后续FAISS用内积计算时得到的就是余弦相似度,不需要自己再除模长。batch_size根据显存或内存调整,文本越长、batch越大越吃内存。我一般先把batch设置成64,如果内存溢出再降到32。convert_to_numpy=True是为了直接拿到numpy数组给FAISS,避免在torch和numpy之间反复转换,省掉不必要的开销。
如果不用sentence-transformers,而是直接用transformers加载BERT类模型,就必须自己决定池化策略:取CLS向量还是做均值池化。这个决策对SimBERT的影响很大,因为SimBERT的对比学习目标可能已经内置了特定的池化方式。直接取CLS在SimBERT上往往表现一般,所以除非你完全清楚原始实现,否则不建议自己手写池化。
3.2 构建向量库:把已有标注数据全部过一遍SimBERT
假设原始数据是一份CSV,两列分别为text和label。读进来之后先做清洗,这一步比想象中重要。去掉空文本、只含标点的文本,把换行符和连续空格替换成单个空格,这些脏字符会让SimBERT的分词结果发生偏移,向量被推向一个不合理的位置。
import pandas as pd df = pd.read_csv("train.csv", encoding="utf-8-sig") df["text"] = df["text"].astype(str).str.strip() df = df[df["text"].map(len) > 1].reset_index(drop=True) vectors = embed_texts(df["text"].tolist()) print(vectors.shape) # (N, emb_dim) print(vectors.dtype) # float32清洗后调用embed_texts一次性编码全部样本。df["text"].map(len) > 1过滤了空字符串和单字符文本,单字符文本在SimBERT向量空间里的位置一般很边缘,不适合作为邻居参考。向量数组的第二个维度是embedding维度,常见是768,这个值在下一节建FAISS索引时会直接用到。
向量化结果是后续所有参数实验的基础,建议立刻存盘。np.save("train_vectors.npy", vectors)一条命令就能搞定。之后每次改阈值、改邻居数,直接读取npy文件,不需要重新加载SimBERT跑一遍编码,能省下大量时间。这一点在做项目实践时尤其重要,模型权重加载通常要十秒到几十秒,几千条文本的编码也可能要几分钟,重复跑几次会非常烦躁。
3.3 用FAISS建索引并查最近邻:参数怎么设
FAISS索引类型选择是很多人第一次接触faiss时最容易纠结的地方。对于几万到几十万条样本,IndexFlatIP是最稳妥的选择。它不做任何压缩,也不依赖聚类训练,添加向量后直接进行精确搜索,每个查询都计算和全量向量的内积,结果无近似误差。数据量超过百万后在内存和耗时上会有点吃力,那时可以考虑IndexIVFFlat,但小项目没有必要为了花哨而牺牲精确性。
import faiss import numpy as np vectors = np.load("train_vectors.npy") dim = vectors.shape[1] index = faiss.IndexFlatIP(dim) index.add(vectors) print(index.ntotal) # 确认索引里的向量数 k = 5 # 每个样本取5个最近邻(后面去掉自身) distances, indices = index.search(vectors, k + 1) # 多查1个,因为自己一定在最前面index.add(vectors)会把向量全部拷贝进FAISS内部存储,此时内存占用等于向量的原始大小。index.search返回两个数组,distances是内积分数,indices是命中的向量行号。因为我们查询的是全量向量本身,所以每条向量和自己的内积一定是1.0,会排在第一个位置。因此查询时传入k+1,后续循环里通过j == i把自身跳过,剩下的就是真正的邻居。
如果样本量超过百万,可以把索引换成IndexIVFFlat。它有几个额外参数:量化器(常用IndexFlatL2)、nlist(聚类中心数)和nprobe(查询时扫描的聚类数)。nlist一般取4 * sqrt(N),nprobe从4开始调,越大召回越高但耗时也越长。注意IVF索引需要先train再add,训练数据可以直接用原始向量的子集,比如随机抽一万条,不必穷举全量。
3.4 增强样本生成:合并标签、去重、控制比例
拿到查询结果后,开始组装增强数据。下面这段代码里,threshold控制邻居必须达到的最低相似度,max_aug_per_sample限制每条原样本最多产生多少条增强样本。
def generate_augmented(df, distances, indices, threshold=0.8, max_aug_per_sample=3): aug_rows = [] for i, (dist_row, idx_row) in enumerate(zip(distances, indices)): added = 0 for dist, j in zip(dist_row, idx_row): if j == i: continue if dist < threshold: continue aug_rows.append({ "text": df.loc[j, "text"], "label": df.loc[i, "label"], # 继承当前样本标签 "src": f"aug_{i}_{j}", "cos": float(dist), }) added += 1 if added >= max_aug_per_sample: break aug_df = pd.DataFrame(aug_rows) # 去掉重复文本+标签,也去掉与原始样本完全相同的文本 aug_df = aug_df.drop_duplicates(subset=["text", "label"]) orig_set = set(zip(df["text"], df["label"])) mask = ~aug_df.apply(lambda r: (r["text"], r["label"]) in orig_set, axis=1) aug_df = aug_df[mask].reset_index(drop=True) return aug_df这段代码里的max_aug_per_sample=3是我常用的初始值。之前遇到一条模板化文本在近邻里拉出三十多条重复表达,全部吞进去会让模型对这个模板过拟合。逐条限制数量后,增强数据分布明显更平缓。dist < threshold是硬过滤,阈值越大、增强数量越少。实际操作中,如果每条样本的top1距离都达不到阈值,说明这个数据集内部样本间语义差异很大,此时要考虑引入外部无标注文本,而不是硬调低阈值。
生成增强数据后,和原始训练数据合并,重新洗牌。
final_df = pd.concat([ df[["text", "label"]], aug_df[["text", "label"]], ]).drop_duplicates(subset=["text", "label"]).sample(frac=1, random_state=42).reset_index(drop=True) final_df.to_csv("train_augmented.csv", index=False, encoding="utf-8-sig")合并后还要检查各类别数量。增强前1000条,增强后3000条,这个比例算健康。如果增强后某个类别变成了原来的50倍,需要怀疑该类别的文本模板化严重,这种类别在测试集上往往表现虚高。另外,drop_duplicates(subset=["text", "label"])只能去重文本和标签完全相同的样本,如果两条文本不同但语义相同,靠这个去重是去不掉的,需要在下一章用更严格的逻辑处理。
4. 把增强比例、相似度阈值和去重逻辑调好,否则翻车
4.1 相似度阈值怎么定:先看分布,再拍脑袋
阈值直接决定增强数据的质量与数量。很多人第一次跑完直接看结果,发现增强样本数量少得可怜,或者多到爆炸,然后凭感觉把阈值改一改。更靠谱的做法是先统计所有邻居距离的分布,看它集中在哪个区间。
import numpy as np dist_flat = distances[:, 1:].flatten() for low, high in [(0.6, 0.7), (0.7, 0.8), (0.8, 0.9), (0.9, 1.0)]: count = np.sum((dist_flat >= low) & (dist_flat < high)) print(f"cos in [{low}, {high}): {count}")如果大量邻居分布在0.9以上,说明原始数据里存在大量重复模板,此时阈值设置成0.85可能会导致增强样本全是模板变体,缺乏泛化价值。如果大量分布在0.7以下,说明样本间语义距离普遍偏远,0.8阈值下增强数量接近于0,这时候不是阈值的问题,而是这个数据集本身内部语义多样性太高,需要外部语料补充。
初始阈值我建议定在0.85,然后根据增强样本数量调整到一个能产生原数据量30%~50%增量的值。除了数量,还要看质量。从增广结果里随机抽20条,人眼确认语义是否真的接近。文本分类里,语义接近不等于标签可替换,比如“苹果好吃”和“苹果手机好用”在SimBERT向量空间可能距离较近,但标签完全不同,这种样本一旦被增强进去,模型就会被带偏。人眼抽样检查能把这类风险最快暴露出来。
4.2 增强比例与类别均衡:别把少数类变成噪声放大器
增强比例指增强样本数与原样本数的比值,我一般控制在1:1以下。比例太高时,模型会过度拟合邻居之间的共性,而不是学习类别本身的可区分特征。尤其在类别极不均衡的情况下,给少数类做增强要限量,否则少数类里的几条特殊表达会被复制成几百条,模型在验证集上对这个类别的P/R会异常波动。
更稳妥的做法是分类别统计增强前后数量。可以在生成增强数据时保留label列,然后用groupby查看各类别数量变化。建议把增强倍数限制在多数学类最多2倍、少数学类最多3倍以内。如果某个类别的邻居距离普遍偏低,说明该类内文本差异大,强行增强会引入噪声,这时候宁可放弃这个类别的增强,也不要硬凑数。
4.3 验证方式:固定验证集,对比增强前后的宏F1
增强是否有效,最终要看训练结果。在增强之前,先从原始数据里划分一个固定的验证集,这个验证集不能参与向量索引构建,更不能作为邻居被查询到。我习惯把10%的数据留出作为验证集,剩余90%做增强,并且确保增强操作只读取训练集部分。
验证流程很简单:先用原始训练集训练一个分类模型,记录验证集的宏F1;再用增强后的训练集训练同样的模型,保持随机种子、超参数不变,对比F1。只有验证集有明显提升,增强方案才算成立。如果F1没有提升甚至下降,优先检查三件事:阈值是否过低、增强比例是否过大、验证集里有没有文本出现在训练集的邻居列表里。
这里要特别提醒一点:划分验证集后,构建FAISS索引时只能使用训练集文本。如果把全量数据都进索引,验证集的文本很可能出现在某个训练样本的topK邻居中,增强后的训练数据就间接包含了验证集信息的近似复述,这会让你在验证集上的表现虚高,上线后立刻打回原形。这一步是很多项目实践里最容易忽略的泄露风险。
5. 避坑:SimBERT+FAISS最近邻增强的五个常见翻车现场
5.1 最近邻全是同一句话的不同前缀
现象:查出来的top5邻居文本高度雷同,只是句子被截断了,或者多了一个标点。
原因:短文本在SimBERT向量空间中容易聚在一起,加上原始数据里有大量重复模板,没有清洗就直接建索引。
解决:先做文本归一化,去掉首尾空格、全半角统一、过滤长度小于4的句子,再做向量化。如果原始数据里确实存在大量模板化表达,先对文本做去重,再进FAISS索引。这里的去重要基于文本本身,而不是基于向量距离。
5.2 用了faiss-gpu后显存爆掉
现象:IndexFlatIP添加几百万向量后,程序直接OOM,连模型都加载不了了。
原因:IndexFlatIP是精确索引,向量全量存在显存或内存里,没有压缩。GPU资源分配不当,索引常驻显存,挤压了后续训练模型的显存配额。
解决:样本量大时改用IndexIVFFlat,先训练聚类再添加向量;或者在CPU内存里建索引,搜索时再拷贝到GPU。注意IndexIVFFlat需要提供训练向量集,可以用原始向量的子集。如果业务要求实时性不高,直接全程用CPU版faiss更省心,几万条数据的效果差距并不明显。
5.3 距离普遍集中在0.9以上,阈值形同虚设
现象:内积0.9以上的邻居非常多,过滤后增强样本数量依然爆炸,而且很多邻居语义并不一致。
原因:SimBERT对某些领域文本的区分度有限,归一化后高维向量的点积普遍偏高,绝对值高不代表语义就一定相近。
解决:不要只看内积绝对值,改用相对排名。把threshold替换成“每个样本只取top2或top3邻居”,不设绝对阈值,然后通过人工抽查控制质量。另一个办法是改用FAISS的IndexFlatL2,观察欧氏距离分布,不同数据集中总有一个距离度量更有区分度,需要实际对比再决定。
5.4 验证集上F1不升反降
现象:增强后的训练集在验证集上的宏F1比基线低两个点。
原因:最常发生在阈值过低或max_aug_per_sample过大时,增强样本里混入了错误标签,模型学到了伪造特征。
解决:立即提高阈值,收紧每样本增强数量,并且对比去掉某一类增强后的效果。同时检查验证集里有没有文本是训练集增强邻居的近似复述,如果有,说明你在划分之前就混入了验证集文本参与索引。这也是我前面强调固定验证集的原因。
5.5 加载SimBERT权重时各种不兼容报错
现象:报KeyError: 'bert/pooler/dense/kernel'之类的TF checkpoint读取失败,或者模型输出向量全部相近,完全无法区分样本。
原因:SimBERT原始权重是TensorFlow格式,直接塞给PyTorch的BertModel不认,或者缺少了sentence-transformers需要的最小文件结构。
解决:先转换成PyTorch格式再加载。transformers官方提供了convert_tf_checkpoint_to_pytorch.py脚本,转换时记得把config里的model_type设成bert。如果不想做转换,也可以找社区已导出的sentence-transformers目录,直接用SentenceTransformer加载。这里不建议自己去改transformers源码强行加载TF权重,坑太多。
6. 进阶用法与落地技巧:把最近邻增强做成可持续迭代的组件
这套方案不应该只在一次实验里闪一下,我建议把它拆成三个长期可用的能力:向量缓存、索引复用和增量更新。
向量缓存的意义在于,原始标注集在增长,每次新增几十条数据不需要把全量文本重新过一遍SimBERT。常见做法是把已处理的文本哈希后作为缓存键,新增文本只编码一次,然后追加到原来的npy文件末尾。FAISS索引里也做同样处理,建一个基于ID的索引,让每条文本与向量ID一一对应,这样才能知道最近邻对应的是哪条原始文本。
如果业务是持续进件的,比如每天来一批新标注数据,我建议用FAISS的IndexIDMap包装IndexFlatIP,这样删除旧样本和追加新样本时,不需要重建全部索引。当然,IndexFlatIP本身不支持删除,但你可以定期重建,或者用IndexIVFFlat这类支持remove_ids的索引结构。对于多数中小规模项目,每周一次重建索引的代价完全可以接受。
另一个容易出效果的进阶方向是引入外部无标注数据。把几十万条与业务相关的无标注中文语料也编码后放进同一个FAISS索引,查询时可能为某条训练样本找到外部语义邻居。此时没有真实标签,只能打伪标签,所以阈值要拉高到0.9以上,而且只取距离最近的那一个邻居。这种方法适合冷启动场景,我在电商评论分类里靠它把准确率往上抬了两个点,但前提是外部语料的领域贴得很近,否则基本都是噪音。
最后,每次增强后要做一次“同标签一致性抽查”。打印出原始样本和它被增强出来的5个邻居,人工看一遍,这能最快发现阈值和模型选型的问题。我自己的教训是:有一次阈值设在0.75,增强数据量虽然大了,但模型在真实线上数据上明显变差,后来查下来是SimBERT把“价格便宜”和“质量不错”这类对比词当成了近邻,这种错误人眼一眼就能看出来,但距离分数很难看出来。
你如果也在做中文文本分类且标注量卡在半万以下,这套SimBERT+FAISS的最近邻数据增强方法值得花一两个下午跑一遍。先按最小流程跑通,再调阈值和比例,最后再考虑外部语料。希望帮到你。
本文还有配套的精品资源,点击获取