数据从 10 万涨到 100 万,模型为什么反而更差?Embedding 数据治理实战

训练句子嵌入模型时,“再加一些数据”是最自然的优化方向。但数据量增长并不等于有效信息增长:同一条点击日志复制十次,会放大热门样本;把同一文档的相邻 chunk 随机分到训练集和测试集,会让指标虚高;把真正相关的文本挖成 hard negative,则会直接制造冲突训练信号。

因此,数据工作的目标不是把文件洗得整齐,而是回答四个问题:

  1. 样本表达的相关性是否符合业务任务?
  2. 训练、验证和测试之间是否真正隔离?
  3. 数据分布是否覆盖线上核心与长尾场景?
  4. 每次数据变化是否可追踪、可复现、可回滚?

本文以检索型 embedding 为主,给出样本设计、清洗、去重、分组切分、评估集构建和自动审计的完整做法。

一、先定义样本,否则所有统计都可能失真

“我们有 100 万条数据”这句话缺少单位。100 万次点击、100 万个唯一 query、100 万个 query-document 对,价值完全不同。

常见任务的样本单位如下:

任务 基本样本 关系含义
语义相似度 sentence_a, sentence_b, score 两段文本语义有多接近
二分类匹配 query, document, label 文档是否与查询相关
对比检索 query, positive 文档能否满足查询
三元组检索 query, positive, negative 正文档应比负文档更匹配
向量建库 document_id, chunk_id, text, metadata 可检索证据及其来源

推荐先把复杂来源统一成可审计的 pair 形式:

1
2
3
4
5
6
7
8
9
10
{
"query_id": "q-1024",
"document_id": "doc-88#chunk-3",
"leakage_group": "intent-json-file-reading",
"query": "Python 如何读取 JSON 文件?",
"document": "打开文件后,将文件对象传给 json.load。",
"label": 1,
"source": "human-v2",
"event_time": "2026-07-01T10:00:00+08:00"
}

三元组可展开为一条 label=1 的 positive pair 和一条 label=0 的 negative pair。统一表示便于检查重复、标签冲突和来源分布;训练前再按 loss 所需格式组装。

leakage_group 为什么不能省

query_id 不一定能识别同义改写,document_id 也不一定能识别同一原文的多个 chunk。leakage_group 表示不能跨数据集拆开的业务实体,例如:

  • 同一个搜索意图的所有改写;
  • 同一原始文档及其所有 chunk;
  • 同一用户会话或工单;
  • 同一商品族的标题变体;
  • 同一个时间窗口内高度关联的事件。

这个字段通常需要在上游构造,而不是靠文本哈希临时猜测。

二、相关不是“关键词相似”,而是符合标注问题

对 query“缓存穿透怎样处理”,以下关系不同:

1
2
3
4
强相关:使用布隆过滤器或缓存空值。
部分相关:缓存穿透会让不存在的 key 持续访问数据库。
高混淆负例:给缓存过期时间加入随机量可缓解缓存雪崩。
无关负例:C++ 条件变量应在循环中检查谓词。

如果任务是问答召回,“定义”可能部分相关但不足以回答;如果任务是主题聚类,它又可能是正确正例。标注规范必须明确任务,而不能只写“相似/不相似”。

一个实用的三级规范是:

  • 2:可以直接支撑回答或完成目标;
  • 1:提供相关背景,但不能独立满足 query;
  • 0:不能满足 query,包括看似相关的干扰项。

训练时可按 loss 映射标签,评估时保留分级相关性以计算 NDCG。不要过早把所有非 2 样本压成同一种负例,否则会丢失业务信息。

三、正样本为什么会有噪声

点击不是天然正样本

用户点击可能因为:

  • 结果排在第一位;
  • 标题吸引人但内容不相关;
  • 用户误触;
  • 没有更好的结果;
  • 点击后立即返回。

点击日志适合弱监督,但应结合曝光位置、停留、后续改写、转化或满意度信号。没有曝光的文档也不能直接标成负样本,因为用户根本没看到它。

结构化关系也会过期

FAQ 问答、商品类目和文档目录通常质量较高,但映射可能已经失效。样本必须保留来源和时间,才能在规则变化时定位并撤回。

生成式改写会放大模板

LLM 能快速生成 query 改写和候选负例,但容易出现语义漂移、事实错误或相似句式重复。生成数据必须抽样复核,并与真实 query 分布分别统计,不能因为便宜就占据训练集大多数。

四、负样本不是越难越好,首先要“真负”

负样本通常分三层:

  1. 随机负样本:帮助模型学习基本主题边界,但很快变得过于简单;
  2. 同域负样本:属于同一主题或类目,但不能满足 query;
  3. hard negative:被 BM25、旧 embedding 或 reranker 排得很高,人工确认后仍不相关。

例如:

1
2
3
4
5
{
"query": "json.load 和 json.loads 有什么区别?",
"positive": "load 接收文件对象,loads 接收字符串。",
"hard_negative": "json.dumps 把 Python 对象序列化成字符串。"
}

这个 hard negative 共享 json 和相近函数名,却没有回答输入区别。

false negative 比简单负样本更危险

语料中可能存在多个正确文档。如果只把已点击文档标正,其他未点击但能回答 query 的文档就可能被当作 hard negative。挖难例时应:

  1. 排除已知正样本和同源近重复;
  2. 使用较强模型或规则初筛;
  3. 对高分候选人工复核;
  4. 允许一个 query 对应多个 relevant document;
  5. 记录负例生成器及版本。

模型越强,挖到的候选越“像正例”,误标风险也越高。

五、清洗不是越彻底越好

比较安全的基础清洗包括:

  • Unicode 规范化;
  • 合并无语义的连续空白;
  • 去除明确的页眉、页脚和模板噪声;
  • 过滤空文本、解析失败和不可用编码;
  • 在合规要求下脱敏敏感信息。

以下操作不能默认执行:

  • 全部转小写:可能破坏类名、基因名或大小写敏感标识;
  • 删除标点:可能破坏 C++::、版本号和错误码;
  • 统一所有数字:HTTP 404HTTP 500 不能变成同一句;
  • 删除 URL:路径、域名和参数可能是检索关键信号;
  • 简繁转换:应与业务语种、模型和展示需求一起决定。

清洗前后都应保留可追踪标识。若偏移、页码或引用需要回到原文,还要保存规范化文本与原文的映射。

六、完全重复、近重复与语义重复不是一回事

完全重复

规范化后文本完全相同,可用哈希高效发现。重复会让高频样本获得额外权重,也可能跨集合泄漏。

近重复

只差页脚、日期或少量字符,可用 n-gram Jaccard、MinHash、SimHash 或专门去重模型筛查。阈值必须抽样评估:过严会漏掉模板变体,过松会误删真正不同的条款。

语义重复

不同措辞表达相同内容。embedding 可用于发现候选,但不能按一个全局相似度阈值自动删除,因为“相似”和“重复”并非同义。

去重前先定义保留策略:保留最新、权威、信息最完整或具有有效业务反馈的样本。否则删除哪一条会变成不可复现的偶然行为。

七、可运行的数据审计脚本

下面的 Python 3.9+ 程序只使用标准库。它演示:

  • 可解释的文本规范化;
  • 稳定、按组的数据切分;
  • 完全重复和标签冲突检测;
  • 来源、标签、集合和长度分布统计;
  • 质量门禁断言。

它是审计 baseline,不包含近重复模型、PII 检测或特定业务规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
from __future__ import annotations

import hashlib
import json
import re
import unicodedata
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass, replace

SPACE_RE = re.compile(r"\s+")

@dataclass(frozen=True)
class PairExample:
query_id: str
document_id: str
leakage_group: str
query: str
document: str
label: int
source: str
split: str = ""

def normalize_text(text: str) -> str:
"""只做 NFKC 和空白规范化,不擅自改大小写、数字或标点。"""
normalized = unicodedata.normalize("NFKC", text)
return SPACE_RE.sub(" ", normalized).strip()

def stable_split(group: str, seed: str = "dataset-v1") -> str:
"""同一 leakage_group 永远进入同一个集合。"""
digest = hashlib.sha256(f"{seed}:{group}".encode("utf-8")).digest()
bucket = int.from_bytes(digest[:4], "big") % 100
if bucket < 80:
return "train"
if bucket < 90:
return "validation"
return "test"

def prepare(rows: list[PairExample]) -> list[PairExample]:
prepared = []
for row in rows:
query = normalize_text(row.query)
document = normalize_text(row.document)
prepared.append(
replace(
row,
query=query,
document=document,
split=stable_split(row.leakage_group),
)
)
return prepared

def percentile(values: list[int], ratio: float) -> int:
if not values:
return 0
ordered = sorted(values)
index = round((len(ordered) - 1) * ratio)
return ordered[index]

def audit(rows: list[PairExample]) -> dict[str, object]:
exact_counts: Counter[tuple[str, str, int]] = Counter()
pair_labels: dict[tuple[str, str], set[int]] = defaultdict(set)
group_splits: dict[str, set[str]] = defaultdict(set)
empty_rows: list[int] = []

for index, row in enumerate(rows):
if not row.query or not row.document:
empty_rows.append(index)
exact_counts[(row.query, row.document, row.label)] += 1
pair_labels[(row.query, row.document)].add(row.label)
group_splits[row.leakage_group].add(row.split)

duplicates = sum(count - 1 for count in exact_counts.values() if count > 1)
conflicts = sum(1 for labels in pair_labels.values() if len(labels) > 1)
leaking_groups = sorted(
group for group, splits in group_splits.items() if len(splits) > 1
)

query_lengths = [len(row.query) for row in rows]
document_lengths = [len(row.document) for row in rows]

return {
"rows": len(rows),
"unique_queries": len({row.query for row in rows}),
"unique_documents": len({row.document for row in rows}),
"empty_row_indices": empty_rows,
"exact_duplicate_excess": duplicates,
"conflicting_pairs": conflicts,
"leaking_groups": leaking_groups,
"labels": dict(sorted(Counter(row.label for row in rows).items())),
"sources": dict(sorted(Counter(row.source for row in rows).items())),
"splits": dict(sorted(Counter(row.split for row in rows).items())),
"query_length": {
"p50": percentile(query_lengths, 0.50),
"p95": percentile(query_lengths, 0.95),
"max": max(query_lengths, default=0),
},
"document_length": {
"p50": percentile(document_lengths, 0.50),
"p95": percentile(document_lengths, 0.95),
"max": max(document_lengths, default=0),
},
}

if __name__ == "__main__":
raw_rows = [
PairExample(
"q1",
"d1",
"intent-json-read",
"Python 如何读取 JSON 文件?",
"打开文件后使用 json.load。",
1,
"human-v2",
),
PairExample(
"q1",
"d2",
"intent-json-read",
"Python 如何读取 JSON 文件?",
"json.loads 用于解析字符串。",
0,
"bm25-miner-v3",
),
# 与上一条完全重复,用来演示审计器发现重复。
PairExample(
"q1-copy",
"d2",
"intent-json-read",
"Python 如何读取 JSON 文件?",
"json.loads 用于解析字符串。",
0,
"bm25-miner-v3",
),
# 同一 query-document 又被标成正例,用来演示标签冲突。
PairExample(
"q1-conflict",
"d2",
"intent-json-read",
"Python 如何读取 JSON 文件?",
"json.loads 用于解析字符串。",
1,
"click-log-v5",
),
PairExample(
"q2",
"d3",
"intent-python-venv",
"怎样创建 Python 虚拟环境?",
"运行 python -m venv .venv。",
1,
"human-v2",
),
]

prepared_rows = prepare(raw_rows)
report = audit(prepared_rows)
print(json.dumps(report, ensure_ascii=False, indent=2))

assert report["exact_duplicate_excess"] == 1
assert report["conflicting_pairs"] == 1
assert report["leaking_groups"] == []

# 真实流水线应在修复重复和冲突后启用这些质量门禁。
clean_rows = [prepared_rows[0], prepared_rows[1], prepared_rows[4]]
clean_report = audit(clean_rows)
assert clean_report["empty_row_indices"] == []
assert clean_report["exact_duplicate_excess"] == 0
assert clean_report["conflicting_pairs"] == 0

print("quality gate passed")
print(json.dumps([asdict(row) for row in clean_rows], ensure_ascii=False))

代码中的三个设计点

第一,使用 SHA-256 而不是 Python 内置 hash() 分组。内置哈希默认可能在不同进程间随机化,不适合生成长期稳定的数据集合。

第二,按 leakage_group 切分,而不是按行随机。以后新增同组样本时,它仍会进入相同集合。

第三,冲突键使用规范化后的 (query, document),能发现同一 pair 同时被标正负。真实项目还应结合 canonical query ID、原始文档 ID 和近重复簇检查,因为改写文本不一定完全相同。

为什么示例没有自动删除坏样本

冲突不代表某一条必然错误。人工标注、点击和挖掘器可能反映了不同相关性定义。审计器应先报告来源,再由规则或人工决定:修标签、降权、保留多级相关度,还是删除样本。自动“保留最新一条”会把数据问题藏起来。

八、切分比例不如切分边界重要

8:1:19:0.5:0.5 都可以作为容量起点,但无法保证测试可信。以下对象一旦跨集合,就可能泄漏:

  • 同一 query 的改写;
  • 同一文档的不同 chunk;
  • 同一 FAQ 的标题与正文;
  • 同一用户会话;
  • 同一模板生成的近重复文本;
  • 时间上紧邻且共享结果的事件。

随机切分还是时间切分

随机分组切分适合评估同分布泛化;时间切分更接近“用过去预测未来”,能暴露新术语和分布变化。搜索与知识库项目可同时保留:

  • 稳定分组测试集:横向比较模型;
  • 滚动时间测试集:观察最新分布;
  • bad case 回归集:保证高价值错误不复发。

测试集应尽量冻结。若反复根据测试结果调参,它就逐渐变成验证集,需要另留最终盲测集。

九、数据分布至少要按五个维度观察

1. 长度

分别统计 query 与 document 的 token 长度 P50、P95、P99 和超长截断率。字符长度只能做快速诊断,最终应使用与模型相同的 tokenizer。

2. 标签与每个 query 的候选数

不仅看全局正负比例,还要看每个 query 有多少 positive、negative。某些 query 有上百条负例而另一些只有一条,会改变训练权重。

3. 来源

人工、点击、结构化映射、LLM 生成、BM25 挖掘和旧模型挖掘应分别统计。不同来源的噪声不能用一个平均准确率掩盖。

4. 热门与长尾

按 query 频次分桶,检查训练样本和线上流量的占比。热门 query 重复采样过多,可能让模型记住少数表达;完全均匀采样又可能忽视真实业务权重。

5. 场景切片

至少区分 FAQ、长文档、代码、精确术语、否定表达、数字/版本敏感和多语言等场景。模型总分只有结合切片才可解释。

十、怎样设计真正有用的评估集

检索评估集应包含:

1
2
3
query_id -> query text
document_id -> corpus text
query_id -> relevant document IDs(可多个、可分级)

只保存孤立的正负 pair 无法完整模拟在真实 corpus 中的排名。负例也不应只来自人工挑选的几条,因为线上文档库里的其他文本同样是候选。

Sentence Transformers 的 InformationRetrievalEvaluator 接受 queriescorpusrelevant_docs,可计算 Recall、MRR、NDCG 等指标。接口与指标用法见句子嵌入模型开发与上线

标准集、时间集和回归集各司其职

数据集 主要目的 更新策略
标准评估集 稳定比较不同实验 严格版本化,少更新
时间外测试集 评估新分布泛化 按周期滚动
bad case 回归集 防止关键错误复发 持续追加,保留来源
最终盲测集 做阶段性决策 限制访问和调参次数

bad case 集不应替代标准集。若只训练和评估困难样本,模型可能修好投诉场景,却损害普通流量。

十一、标注质量怎样量化

人工抽检要覆盖随机样本,也要对高风险切片加权:

  • 来源首次接入的数据;
  • 模型与标签严重冲突的数据;
  • 高相似 hard negative;
  • 高流量 query;
  • 新术语和长尾场景。

多人标注时应记录一致率,并定期复盘分歧。简单准确率可能被类别不平衡误导,可同时看每类混淆矩阵或适合任务的标注一致性指标。数字不能代替规则讨论:两位标注员若对“部分相关”定义不同,一致率下降只是症状。

黄金样本应带标注规范版本。规则变化后,不要悄悄覆盖旧标签,而应迁移或重新标注并生成新数据版本。

十二、数据质量门禁应该检查什么

每次生成训练快照时,至少自动输出并与上一版本比较:

  • 总行数、唯一 query 和唯一 document 数;
  • 空值、解析失败和非有限标签;
  • 完全重复率、近重复候选率;
  • 同 pair 标签冲突率;
  • 跨 split 的 group、文档和近重复泄漏;
  • 各标签、来源、语言、场景和时间占比;
  • query/document token 长度分位数与截断率;
  • 每个 query 的正负样本数量;
  • hard negative 的人工抽检通过率。

门禁阈值不应凭空设定。例如“重复率必须为 0”未必合理,因为同一 query 可合法对应多个来源相同的正例;更稳妥的是先建立上一稳定版本的分布,再对异常变化报警,并对必须为零的硬错误单独失败。

十三、数据版本要能复现一场训练

“使用最新数据”不是版本。一次训练至少要绑定:

1
2
3
4
5
6
7
8
9
10
11
{
"raw_snapshot": "search-events-2026-07-01",
"schema_version": "retrieval-pair-v3",
"normalizer_version": "text-normalizer-v2",
"label_policy": "relevance-guideline-v4",
"split_seed": "dataset-v7",
"negative_miner": "bm25-plus-encoder-v5",
"train_snapshot": "retrieval-train-2026-07-08",
"evaluation_set": "kb-gold-v6",
"badcase_set": "kb-regression-v12"
}

还应保存代码提交、配置、统计报告和不含敏感信息的样本谱系。原始日志可能受隐私和保留期限约束,版本管理不等于无限复制敏感数据;应遵守项目的数据治理策略。

十四、指标变差时,按什么顺序排查

训练 loss 很低,测试指标异常高

先查跨集合重复、同文档 chunk、同义 query 和模板泄漏。不要急着庆祝模型能力提升。

新数据加入后 Recall 下降

对比新旧快照的来源、场景、长度和标签分布;抽查新增 hard negative 是否含假负例;检查热门 query 是否被重复放大。

热门 query 好,长尾很差

按频次分桶查看训练占比和测试 Recall。调整采样或补充长尾并不意味着把线上真实流量权重完全抹平,应通过业务目标决定权衡。

离线好,线上差

检查评估 query 是否来自真实分布、时间是否过旧、线上预处理与 corpus 快照是否一致,以及点击指标是否受展示和排序影响。

hard negative 越加越差

抽样复核“最难”的负例。它们很可能是未标注正例、部分相关文本或缺少上下文的 chunk。

每次训练波动很大

固定数据快照、切分 seed 和评估集;统计每个 query 的候选数;再检查训练随机性。没有可复现数据,模型侧的 seed 也救不了实验。

十五、一条务实的落地路线

  1. 写清任务、样本单位和分级相关性规范;
  2. 保留 query/document 原始 ID、来源、时间和 leakage group;
  3. 做克制、可追踪的文本规范化;
  4. 先建立完全重复、冲突和分组泄漏门禁;
  5. 统计长度、标签、来源、热门/长尾和场景切片;
  6. 用随机负例建立基线,再逐步引入经复核的同域与 hard negative;
  7. 建立稳定标准集、滚动时间集和 bad case 回归集;
  8. 固定快照和版本清单后再训练;
  9. 模型失败时将 bad case 回流为可复现标注;
  10. 每次数据变更都与上一稳定版本做差异报告。

这套流程不要求一开始就部署复杂数据平台。一个可重复运行的脚本、一份明确 schema、一组质量断言和不可变快照,已经比没有边界的“最新 CSV”可靠得多。

总结

Embedding 数据治理不是删空行、去乱码这么简单。它要保证正例真能满足 query,负例足够有区分度且确实为负,相关实体不会跨集合泄漏,评估集能代表真实业务,每次变化还能被追溯。

数据规模决定模型看过多少样本,数据关系决定模型学到什么,评估设计决定你能否看清它学得对不对。

可以用一句话记住:

先让数据可解释、可审计、可复现,再讨论把模型训练得更大或更久。