ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于faiss与Chinese SimBERT的中文NLP最近邻数据增强实践

基于faiss与Chinese SimBERT的中文NLP最近邻数据增强实践 简介面向中文NLP数据增强场景的实践项目利用faiss索引与Chinese SimBERT向量化实现最近邻样本扩充主要解决标注数据不足时如何高效补充相似文本的问题。资源共6个文件压缩包55.06MB包含3个csv数据集文件、1个yaml配置文件、1个faiss索引文件和1个Python数据增强脚本覆盖从配置、索引构建到数据输出的完整链路。已有197人学习。项目基于Python 3.7、bert4keras 0.11.3与faiss-gpu 1.7.2思路清晰先由无标签语料构建faiss索引再为带标签样本检索最近邻最终生成含相似文本、相似度与标签的扩充数据。脚本与配置可直接参考或二次开发有助于快速落地基于向量检索的中文数据增强流程适合NLP项目实践与算法调参场景。1. 数据增强为什么不先想到生成模型而先动 faiss 做最近邻做过中文 NLP 分类任务的工程师应该都有同感标注数据永远不够尤其是带 label 的业务语料攒几个月也就几千条。常见的增强思路是回译、EDA 同义词替换但对中文而言回译依赖翻译接口批量跑又慢又贵EDA 的词表替换在领域语料上经常把糖尿病换成糖果病这种离谱结果。有一种更省心的做法是先用 faiss 建一个语义查找表把已有标注数据用 Chinese SimBERT 向量化同一 batch 里做最近邻查询再从邻居句子里产生新样本。这条路的好处是全程本地运行不依赖外部接口而且最近邻本身就能反映数据分布密度适合把稀疏类别的样本往稠密区域补。这个方向适合谁你手里已经有一批干净的中文标注数据想在不改模型结构的前提下扩充训练集或者你刚接手一个冷启动项目只有几百条高质量正例需要快速让分类器不至于一训练就过拟合。本文会用可复现的做法讲清楚为什么选 SimBERT 做向量化、faiss 的索引和查询参数怎么定、最近邻能生成哪几种增强样本以及最容易让你翻车的五个细节。2. 向量化与索引选型chinese simbert 配 faiss动手前先想明白三件事2.1 为什么用 chinese simbert而不是 BGE、M3E 或通用 BERT提到中文句向量现在社区里更常聊的是 BGE 和 M3E它们在语义相似度榜单上表现不错。但做数据增强有一个容易被忽略的点我们需要的是局部语义一致而不仅仅是全局相似度排名。SimBERT 是苏剑林基于 BERT 做的一个句向量模型它的训练目标里包含了句对相似和句对生成两个任务。什么意思呢它的向量空间在同类句子上挤得更紧而且相邻句子往往在字面上也有可替换的关系。比如这款手机电池耐用和这款机器续航时间长在 SimBERT 的向量空间里距离比较近而在通用 BERT 的向量空间里可能被拉到话题相关但不字面相近的区域。做增强时我们恰恰希望邻居是字面可替换的而不是话题沾边但结构完全不同的。另外SimBERT 的语义表示对中文的字粒度更敏感。因为是全词掩码WWM训练出来的它对哪 / 里 / 的 / 服 / 务这种分词边界模糊的中文短语有更好的稳定性。实际使用中我用通用 BERT 做向量化喂给 faiss 查出来的邻居经常出现用户反馈和客服反馈这类同义不同结构的样本拼接出来的句子生硬换成 SimBERT 之后邻居句子的结构相似度明显提高。使用上的注意点SimBERT 输出的是 768 维句向量如果你在 transformers 里加载记得取出 CLS 位置的输出或者对非 padding 位置做均值池化不要直接用 last_hidden_state 的高维张量丢给 faiss那样索引维度会变成序列长度乘 768既慢又没有意义。2.2 faiss 索引类型怎么选IndexFlatIP 与 IndexIVFFlat 的取舍faiss 官方文档把索引分得很细但做中文文本最近邻常见做法其实就是在两种里挑精确索引和倒排索引。精确索引用 IndexFlatIP。它做的事情简单粗暴把所有向量全部平铺在内存里每次查询都做一次全量内积计算。优点是召回率 100%不需要调参缺点是数据量上了 10 万条之后单条查询耗时可能会到十几毫秒批量增强时要循环几万次整体时间不可接受。倒排索引用 IndexIVFFlat。它先对全量向量做聚类nlist 参数控制簇的个数查询时只在最近的几个簇里搜索nprobe 参数控制查询的簇数。实测中5 万条样本、nlist100、nprobe10 的情况下召回率能保持在 95% 以上单条查询耗时降到 1 毫秒以内。做增强场景如果你的数据集在 1 万条以下直接用 IndexFlatIP 最省心超过这个量级带上 IndexIVFFlat。还有一个折中方案是把向量先降维到 256 维再建索引但中文语义表示降维后丢失的信息不容易找回所以我不推荐为了速度去动向量维度。索引类型适合数据量参数召回率典型耗时5 万条CPUIndexFlatIP 1 万条无100%3-5 ms/条IndexIVFFlat1 万 - 100 万条nlist, nprobe95%nprobe 足够时 1 ms/条IndexHNSWFlat向量维度高、查询频繁M, efSearch高快但建索引较慢2.3 建索引之前必须先做的两个预处理向量正则化与 ID 映射faiss 做内积相似度对向量模长非常敏感。如果你没有把句向量做 L2 归一化那么长句子的向量模长普遍比短句子大查询结果会被句子长度这个无关因素主导。这是做最近邻增强时最容易踩的坑。建议在向量化之后统一做一次归一化把内积相似度变成余弦相似度。另外faiss 的索引内部只认它自己分配的序号0 到 N-1。如果你有一条原始数据的 ID 是 10086写入 faiss 时它在索引里的位置可能是 38。查询返回的 38 这个值你需要通过一个映射表还原成原始 ID。最稳妥的做法是使用 IDMap 类型的索引比如 IndexIDMap2 包住 IndexFlatIP直接把原始数据 ID 传进去。3. 从原始中文文本到最近邻列表完整的数据管线脚本3.1 文本清洗与 chinese simbert 向量化的最小实现先交代一下我的做法拿到原始数据后我不会立刻向量化而是先做一遍轻量清洗。清洗规则按你的业务数据来常见做法包括统一全半角、去掉 HTML 标签、规范重复标点连续三个以上的逗号 / 句号压缩成一个、以及根据业务词表过滤无效字符。清洗完的文本统一写入一个列表然后开始向量化。下面是最小可运行的代码import numpy as np from transformers import AutoTokenizer, AutoModel import torch # 载入中文 SimBERT 模型与分词器 model_name 你的 SimBERT 权重路径或 Hugging Face 模型名 tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModel.from_pretrained(model_name) model.eval() texts [这款手机电池耐用, 这个机器续航时间长, 屏幕碎了售后不管] encoded tokenizer( texts, max_length64, paddingTrue, truncationTrue, return_tensorspt ) with torch.no_grad(): outputs model(**encoded) # 取 CLS 位置的向量作为句向量表示 cls_vectors outputs.last_hidden_state[:, 0, :].numpy() # L2 归一化把内积变成余弦相似度 norms np.linalg.norm(cls_vectors, axis1, keepdimsTrue) cls_vectors cls_vectors / norms print(cls_vectors.shape)代码逻辑说明我先用批量化方式一次处理多个文本避免单条循环过慢。max_length64控制截断阈值如果你的句子普遍较长可以调整到 128但 SimBERT 的位置编码上限是 512超过部分会被截断后面避坑章节会细说。取 CLS 向量是因为 SimBERT 在训练时用 CLS 输出做相似度损失它比均值池化更贴合这个模型本身的语义空间。参数说明paddingTrue会让一个 batch 内所有句子补齐到最长但请留意padding 出来的[PAD]token 也会参与注意力计算我建议你改造为 Attention Mask 让模型忽略 padding 位上面的最小实现没有体现正式代码里要加上attention_maskencoded[attention_mask]否则短句的向量会被无效位稀释。3.2 用 faiss 建索引并完成最近邻查询直接返回中文候选向量化完成后下一步就是让 faiss 上场。完整流程先把向量写入索引再对每一条数据查询它的最近邻。import faiss # 把所有向量写入 IndexFlatIP并绑定原始 ID dim cls_vectors.shape[1] index faiss.IndexFlatIP(dim) index faiss.IndexIDMap2(index) # 原始 ID 列表长度必须与向量数量一致 original_ids np.array([0, 1, 2], dtypenp.int64) index.add_with_ids(cls_vectors, original_ids) # 查询对每一条数据找 top-k 最近邻 k 5 scores, neighbor_ids index.search(cls_vectors, k) # 打印第 0 条样本的邻居 for idx, score in zip(neighbor_ids[0], scores[0]): print(f原始ID: {idx}, 相似度: {score:.4f}, 文本: {texts[int(idx)]})代码逻辑说明IndexIDMap2包住IndexFlatIP后add_with_ids会把原始 ID 和向量绑定查询返回的neighbor_ids就直接是原始 ID省去手动映射的麻烦。这里有个细节search的返回结果里会包含自己因为自己是自己的最近邻所以k5实际上只带来 4 个有效邻居业务代码里建议把k设置成 6取时跳过第一个。参数说明search函数第二个参数代表返回最近邻个数。对于数据增强我一般设k10或k20太少候选不够用太多后面的邻居相似度太低垃圾样本会污染训练集。另外 faiss 的 inner product 分数范围是 [-1, 1]归一化后但 Cosine 相似度的数值不能直接当置信度用建议只做排序不设绝对阈值。3.3 三个必调参数阈值、候选数量、标签平衡拿到邻居列表之后实际增强效果取决于三个参数。第一个是相似度阈值。我一般设置一个保留阈值比如 0.75只有相似度高于这个值的邻居才参与增强。阈值设太低会把语义不相关的样本硬拉进来模型被噪声带偏设太高可用的邻居太少增强等于没有。建议先在验证集上做一个快速扫描从 0.6 到 0.9每 0.05 试一次用增强后的数据训练一个小模型看指标变化。第二个是每类样本的候选数量。如果某个类别的样本很少比如只有 100 条但它的邻居特别多不要一口气全部用完。我的习惯是控制增强倍数比如 3 倍到 5 倍防止某类样本在训练集里占比被抬得太高。第三个是标签平衡策略。最近邻增强天然会加剧头部类别越来越多的马太效应因为样本多的类别向量密集更容易被查出来。对于尾部类别我会单独做一次反向查询把尾部类别的每一条样本作为 query从全量数据里找最近邻然后只保留同标签或低置信度的邻居这样能定向补足稀疏类别。4. 最近邻如何变成新的中文训练数据三种可直接落地的策略4.1 词级替换增强把句子中的低频词换成邻近样本的高频词拿到最近邻之后最保守的增强方式是词级替换。做法是对样本 A找到它的最近邻样本 B两个句子分词后把 A 中一个低频词替换成 B 中对应语义槽位的词。这个做法比 EDA 的随机同义词替换更安全因为替换词来自真实语料不是外部词表。import jieba def word_level_augment(text_a, text_b, top_k1): 把文本 A 中的低频词替换为近邻文本 B 中的词。 words_a list(jieba.cut(text_a)) words_b list(jieba.cut(text_b)) # 这里简化处理假设 A 和 B 长度接近按位置对齐 if len(words_a) ! len(words_b): return text_a new_words [] for i, (wa, wb) in enumerate(zip(words_a, words_b)): # 低频词在语料中出现次数低于 3 的词才有替换价值 if word_count.get(wa, 0) 3 and wa ! wb: new_words.append(wb) else: new_words.append(wa) return .join(new_words)代码逻辑说明这个实现是简化版本核心是低频词 近邻高频词的组合。word_count是预先统计的全局词频表你需要在自己语料上统计一遍。注意两个句子的长度不一定相等实际落地时我会先用动态规划做一个最小编辑距离对齐让替换位置更准确。参数说明top_k控制替换次数设置为 1 表示每句话最多替换一个词。不要贪多替换太多句子原意就散了。替换后需要人工检查一条如果替换出来的词明显不通顺比如把手机替换成充电器说明这个近邻不适合词级增强丢弃即可。4.2 句子级拼接增强前句加后句配合标签平滑词级替换只适合短句对于长文本或评论型数据更有效的是句子级拼接。做法很简单把样本 A 和它的最近邻 B要求同标签拼接成一条新样本。这里的关键是拼接方式。我试过直接在原句上追加效果不好因为两个句子之间没有衔接词模型会把它们当成两句无关的话。常见做法是加一个过渡符号比如 [SEP] 或中文的。def pair_concat_augment(text_a, text_b, label, label_smooth0.1): 近邻句子拼接增强返回新文本与平滑后的标签分布。 new_text text_a text_b # 标签平滑给错误类别一点点概率防止模型过度自信 smooth_label [label_smooth / (num_classes - 1)] * num_classes smooth_label[label] 1 - label_smooth return new_text, np.array(smooth_label)代码逻辑说明拼接增强的收益来自语义互补。分类任务里模型经常因为关键词缺失而误判比如售后不管这条样本没有提到客服但它的近邻里有客服响应慢拼接后模型能同时看到两个特征。标签平滑是为了防止模型记住出现某词必然属于某类这种捷径平滑系数 0.1 是经验值如果你的分类类别数较少二分类可以下调到 0.05。参数说明拼接时要注意总长度两个 64 字的句子拼起来是 128 字如果模型 max_length 是 64后半个句子会被截掉。我在这个环节会重新分词并做长度判断超长时优先保留前半句加后半句的开头几个词而不是直接丢弃。4.3 伪标签增强用近邻投票给未标注语料打标第三种策略适合你手里有一大批无标签数据的情况。做法把所有无标签数据也用 SimBERT 向量化查询它们在已标注数据中的最近邻用邻居标签投票决定这条数据的伪标签。def pseudo_label_by_neighbors(neighbor_labels, neighbor_scores, threshold0.8): 根据最近邻标签投票生成伪标签。 neighbor_labels: 邻居的标签列表 neighbor_scores: 邻居的相似度分数 label_score {} for lab, score in zip(neighbor_labels, neighbor_scores): if score threshold: continue label_score[lab] label_score.get(lab, 0) score if not label_score: return None, 0.0 # 取加权得分最高的标签 pseudo_label max(label_score, keylabel_score.get) confidence label_score[pseudo_label] / sum(label_score.values()) return pseudo_label, confidence代码逻辑说明这里把相似度分数直接当作投票权重比单纯数票数更合理。比如一个邻居相似度 0.9、标签 A另一个邻居相似度 0.7、标签 BA 的加权得分明显更高。threshold0.8是一个起始值实际业务中如果无标签数据分布很散0.8 会过滤掉大部分样本可以放宽到 0.7但要接受一些噪声。参数说明伪标签增强有个前提——已标注数据的分布要能覆盖无标签数据的主体。如果无标签数据里有一类样本在你的标注集里完全没有近邻打出来的伪标签就是瞎猜宁可丢弃这批数据。另外伪标签数据参与训练时建议把 loss 权重设置为 0.5 倍让真实标签的主导地位不被冲掉。5. 避坑与排查faiss 返回结果与 SimBERT 向量碰到的五个典型问题5.1 faiss 返回的是内部序号不是原始索引号现象查询结束后拿返回的数字去原始数据里取文本取出来的内容张冠李戴。原因faiss 的 IndexFlatIP 默认按添加顺序分配内部序号如果中间有数据被删除或过滤过内部序号和原始 ID 就错位了。还有一个常见场景用add_with_ids传了原始 ID 但建的是普通索引faiss 会忽略你传入的 ID仍然按自增序号处理。解决统一使用faiss.IndexIDMap2包装类别索引。注意是IndexIDMap2而不是IndexIDMap前者内部用哈希表支持按 ID 删除单条向量后者不支持。建索引和查询的代码都要走 IDMap 的接口避免中途混用。5.2 长文本被截断向量化时把语义丢了现象两条明显意思相反的句子这个产品我喜欢 vs 这个产品我不喜欢向量距离非常近查询出的最近邻基本都是错误标签。原因SimBERT 的位置编码上限是 512但你设置max_length64或 128 后超过长度的文本被硬截断。如果一条文本的核心表达恰好落在截断区域比如不喜欢出现在第 70 个 token 位置截断后剩下的部分全是无关内容。解决先统计文本长度分布把max_length设置为覆盖 95% 样本的长度而不是拍脑袋定 64。如果业务数据普遍较长可以考虑分段向量化再取平均但注意 SimBERT 的句向量是整体语义分段平均对短文本聚合任务有效对判断整体情感不一定有效需要做 A/B 验证。5.3 向量没做 L2 归一化内积被句子长度带偏现象查询出来的最近邻永远是长句子相似度分数普遍偏高短句子几乎不会被召回。原因faiss 的 IndexFlatIP 算的是内积内积 模长 × 余弦相似度。长句子向量模长天然偏大即使方向和查询向量不接近内积也能排到前面。解决向量化之后统一做 L2 归一化。上面代码里已经写了norms np.linalg.norm(...)这一步批量处理时不要遗漏。有个细节如果你之后又对向量做过投影或降维需要再归一化一次。5.4 增强后标签翻转模型分数更差了现象加了增强数据之后模型在验证集上的 F1 反而掉了 2 个点。原因最常见的两个原因。一是阈值设得太低拉进来的邻居语义不相关比如退款慢和收货慢被当成同义样本拼接生成的新文本语义反转二是拼接增强时两个邻居标签虽然一致但一个是手机屏幕碎一个是电脑蓝屏拼接后模型学到了屏幕碎 蓝屏 售后问题这种不稳定的特征。解决把增强后的数据单独存一份抽样 20 条人工看一遍重点看拼接处是否产生歧义。同时把相似度阈值往高调直到人工检查的合格率超过 90%。另外词级替换和拼接增强不要叠加使用叠加后的噪声不是一加一等于二而是指数级放大。5.5 nprobe 和 nlist 调参失控耗时与召回率同时变差现象换成 IndexIVFFlat 之后查询速度确实快了但返回的最近邻明显不合理或者查询时间反而变长了还没精确索引快。原因nlist决定聚成多少个簇nprobe决定查询时搜索几个簇。nlist太小每个簇里样本太多nprobe也覆盖不了全局分布nlist太大一个簇里就几条向量nprobe要设得很高才能保证召回速度就退化了。这个参数组合在很多工程里被当成玄学调。解决给出一个可直接复制的经验起点nlist int(sqrt(num_samples))大约在 100 到 500 之间nprobe从 10 开始逐步翻倍观察召回率变化。验证召回率有一个廉价方法用 IndexFlatIP 在小批量数据上先算出精确 top-k再和 IndexIVFFlat 的结果对比准确率超过 95% 的 nprobe 值就是你的候选参数。6. 增强效果不靠感觉批量查询脚本、质量验证参数与一个持久习惯当你把上面一套管线跑通后接下来要做的是让增强流程可复现、可验证、可回滚。我强烈建议你把向量化 - 建索引 - 查邻居 - 生成增强样本整个过程封装成一个脚本输入是原始数据文件和参数配置输出是增强后的训练集。def batch_search(index, vectors, k10, batch_size1024): 分批查询最近邻避免一次性查询占用过多内存。 all_scores [] all_neighbors [] for start in range(0, len(vectors), batch_size): end min(start batch_size, len(vectors)) batch_vectors vectors[start:end] scores, neighbors index.search(batch_vectors, k) all_scores.append(scores) all_neighbors.append(neighbors) return np.vstack(all_scores), np.vstack(all_neighbors)这个脚本的价值是处理大数据量。5 万条向量一次性交给 faiss 的search机器内存不够会直接卡死分批 1024 条查询耗时几乎不变但内存占用下降一个量级。batch_size参数可以按你的内存调整CPU 环境下 2048 也没问题。质量验证是我的习惯动作分四步走。第一步增强前后训练集的大小、正负样本比例、平均文本长度用表格打印出来快速发现是否引入分布偏移。第二步:抽样 30 条人工检查记录通顺且标签正确的比例。第三步用增强前的数据训练一个基线模型用增强后的数据训练一个对比模型在同一个验证集上看 F1 变化。第四步检查增强数据里是否有重复样本faiss 查询出的邻居如果和原样本完全相同说明语料里有重复文本需要先去重再做增强。我踩过最深的坑是对着增强数据的量兴奋不已却没管质量验证结果模型被拼接样本带偏最后花了两天排查才发现是伪标签测试集污染导致评估分数虚高。后来我养成了一个习惯每一批增强出来的数据文件名里都要带上参数 hash比如k10_seed42_thresh0.8_v1.jsonl模型表现异常时能快速回滚到上一版参数。这个习惯帮我避开了很多了无头绪的模型调参困境。最后这个方案最值得投入的地方不是 faiss 本身而是用真实数据分布去指导增强这个思路。生成模型需要精心调 prompt回译需要外部接口而最近邻增强只需要你自己的数据再加一个本地向量检索库它对冷启动和稀疏类别尤其友好。希望这份落地步骤能帮你少走我走过的弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进