连续项目 1/3 · 文本变成 Token
07_text_to_tokens.ipynb 建立字符词表、编码/解码和下一个 Token 训练样本。这里产生的数据合同会被第 8、9 章原样复用,不是做完即丢的一次性示例。
01 · CORPUS FOUNDATIONS
文本与语料:先弄清楚模型看到的是什么
自然语言不是一串“看起来像文字的字符”那么简单。它有 Unicode 字符、字节编码、空白、标点、结构和语境。本节从 Python 的 str 与 bytes 开始,建立清洗、切分、去重、切分数据和任务定义的完整基础。
在提交数据前做一次“反向追问”
看到一条处理后的记录,要能反向回答:它来自哪个文件和字符区间?使用了哪个编码和清洗版本?为什么属于这个 split 和标签?如果答案需要凭记忆补全,说明数据合同还不完整。把这些问题写成检查函数或清单,能让后续的 Token、Embedding 和评估建立在可信输入上。
语料治理也包括删除流程:当来源撤回或用户要求删除时,记录 ID 应能定位原文、chunk、词表统计、向量索引和缓存。学习项目先用 manifest 模拟这条链路,生产项目再接入实际存储和权限系统。可追溯不只为了出错时找原因,也为了知道哪些内容不该继续被模型看到。
把“读不出来”当成数据质量信号
解码失败、空文本、异常控制符和无法识别的许可证,都应进入质量报告而不是被静默跳过。报告记录文件、错误类型、字节数、处理动作和是否需要人工复核;这样下一次采集器升级时,可以比较失败率是否真的下降。对公开资料,保存来源链接和抓取日期也有助于核对内容是否发生变化。
| 断言 | 理由 |
|---|---|
| text 是非空 str | 避免把 None、数字或错误对象送入分词器 |
| raw_hash 与 source 唯一 | 避免同一内容被重复计数 |
| split 已固定 | 避免处理后才临时泄漏 |
| license 有值 | 确认可以用于学习和展示 |
只要一条断言失败,就输出可定位的记录 ID。数据管道宁愿明确少几条样本,也不要用不透明的默认值制造“完整”数据集。
质量抽样:不用读完全部语料,也能及时发现问题
大语料不可能逐条人工阅读,但可以把质量检查设计成分层抽样。先按来源、语言、长度、时间和标签分桶,再从每个桶抽取固定数量;同时保留极短、极长、空值、含异常字符和高频重复样本。抽样报告记录随机种子、桶大小和抽到的 ID,下一次运行才能复现同一批样例。
缺失值要按字段分别处理。缺失标题可能仍可用于分类,缺失正文通常不能用于检索;缺失标签可以进入待标注队列,不能默认为“其他”。对每种缺失决定“保留、填充、删除、待复核”,并记录数量和比例。比例随时间变化时,可能是上游采集器改变,而不是模型突然变差。
| 分层 | 例子 | 应关注的风险 | 最小报告 |
|---|---|---|---|
| 来源 | 网页、手册、用户工单 | 风格和授权混合 | 每源数量/缺失率 |
| 长度 | ≤16、17~128、>128 Token | 截断和上下文不足 | 分桶指标 |
| 时间 | 月份或版本 | 概念漂移 | 时间分布 |
| 语言 | 中文、英文、中英混排 | 分词和编码差异 | 语言切片 |
| 标签 | 类别/待复核 | 少数类与噪声 | 支持数 |
from collections import defaultdict
import random
def stratified_ids(records, per_bucket=2, seed=7):
buckets = defaultdict(list)
for row in records:
length_bucket = "short" if len(row["text"]) <= 32 else "long"
key = (row.get("source", "unknown"), length_bucket, row.get("split"))
buckets[key].append(row["id"])
rng = random.Random(seed)
picked = {}
for key, ids in buckets.items():
ids = sorted(ids)
rng.shuffle(ids)
picked[key] = ids[:per_bucket]
return picked
print(stratified_ids(records))抽样脚本中的随机种子不是质量保证本身,它只是让复核集合稳定。真正的质量证据还包括人工标注意见、异常类型、修复前后数量和处理版本。修复数据后不要覆盖旧报告;保存一份差异清单,说明哪些记录改变以及为什么改变。
当质量问题来自上游时,最有效的修复可能是调整采集或数据合同,而不是在模型前加更多正则表达式。把反馈回传给数据生产者,规定必填字段、合法编码和失败重试,模型团队才能把时间花在表示与任务上。
把语料管道画成一条可审计的流水线
拿到一批文件时,先不要急着调用分词器。第一步是读取元数据:文件名、相对路径、来源、创建或更新时间、许可证和语言。第二步以二进制方式读取并记录字节数,再按照声明的编码解码;解码失败要进入错误清单,不能用一个“尽量修复”的替换字符悄悄吞掉问题。第三步生成规范化副本,同时保留原始哈希。第四步才是切句、切块和构造训练或检索记录。
这条顺序的关键在于每一步都有输入和输出。输入是原文哈希,输出是处理后文本哈希;输入是清洗配置,输出是规则版本;输入是文档 ID,输出是稳定的 chunk ID。只要某个结果无法回答“它由哪一份原文产生”,就还不算可审计。学习时可以把这些字段写进 JSONL;即使只处理十个文件,也要养成不覆盖中间产物的习惯。
编码和规范化还要考虑边界样例:UTF-8 BOM、Windows 换行、混合全角半角、零宽空格、不可见控制符、复制网页带来的不间断空格,以及看起来相同但码点不同的汉字。对每种样例写输入、期望输出和是否保留原文的说明。测试不是为了追求“所有文本都被洗得一样”,而是为了证明规则只在授权范围内改变文本。
| 阶段 | 推荐字段 | 应回答的问题 |
|---|---|---|
| 读取 | source、path、encoding、byte_count | 字节从哪里来、怎样解码 |
| 原文 | document_id、raw_hash、raw_text | 能否还原最初内容 |
| 处理 | preprocess_version、rules、normalized_hash | 哪些规则改变了文本 |
| 切分 | chunk_id、start、end、chunk_config | 片段怎样回到原文 |
| 任务 | label、group_id、split、license | 样本能否用于这个任务 |
import hashlib
import json
from pathlib import Path
def read_record(path):
raw = path.read_bytes()
text = raw.decode("utf-8-sig")
return {
"document_id": hashlib.sha256(str(path).encode()).hexdigest()[:12],
"source": str(path),
"encoding": "utf-8-sig",
"byte_count": len(raw),
"raw_hash": hashlib.sha256(raw).hexdigest(),
"text": text,
}
records = [read_record(path) for path in Path("corpus").glob("*.txt")]
with Path("documents.jsonl").open("w", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
print("documents:", len(records))这段读取器把 BOM 处理写在接口里,并把原始字节哈希存下来。真实项目还应检查文件是否为空、路径是否越界、许可证是否允许处理,以及同一个文档是否被不同路径重复收集。若要把文件内容送给远程服务,先确认隐私和授权边界;学习资料中的示例以公开、可再分发的短文本为宜。
训练、验证和测试的切分必须早于任何会从数据中学习参数的步骤。词表、IDF、均值方差、近似重复阈值和困难负例都只在训练或指定的验证范围内拟合。测试集的文本可以被读取来计算最终指标,但不能反过来影响规则选择。把 split 写进每条记录,比在脚本末尾临时切片更不容易出错。
收集 8 个公开文本样例,为每条记录填写原始哈希、编码、字符数、规范化版本、来源、许可、group_id 和 split。故意加入一个 BOM 文件、一个空文件和一个近似重复文件,观察审计报告能否指出它们。
7.1.1 Unicode 与文本对象:字符、字节和字符串不要混为一谈
在 Python 里,str 表示 Unicode 文本,bytes 表示一串字节。中文、英文、表情和组合字符都可能占用不同数量的字节,但在字符串层面仍可按 Unicode 码点处理。文件读取时指定 encoding="utf-8",是为了告诉 Python 怎样把字节解码成字符;写文件时重新编码,才得到可以传输或保存的字节。若把编码问题推迟到模型阶段,最后看到的往往是乱码、替换字符或长度异常。
“一个字符”也不总等于“一个用户感知的字”。例如带重音符号的文字可以由一个预组字符组成,也可以由基础字符和组合符号组成;某些表情由多个码点组合。对搜索和去重而言,是否做 Unicode 规范化要根据任务决定。规范化有助于把视觉上相同的表示统一,却也可能改变原文细节。保存原文、副本和处理版本,是可追溯文本管道的基本做法。
文本对象还可能来自不同容器:网页响应是 bytes,JSON 解码后是 str,表格单元格可能是空值或数字,聊天记录则常常包含角色、时间和附件字段。进入分词器之前,先定义统一记录结构,例如 {"id": ..., "text": ..., "label": ..., "meta": ...}。不要让下游代码猜测某一列是否已经转成字符串,也不要把缺失文本悄悄转换成字符串“nan”。
同时保存原文和处理后文本,才能解释一次错误到底发生在哪一层。
| 对象 | 示例 | 适合做什么 | 常见错误 |
|---|---|---|---|
| bytes | b"\xe4..." | 读写、传输、哈希 | 直接按字符切片 |
| str | "人工智能" | 清洗、显示、规则处理 | 误以为长度等于字节数 |
| code point | ord("智") | 诊断 Unicode 差异 | 当成语义单元 |
| record | 文本+标签+ID | 数据集与审计 | 丢失来源和版本 |
| Token | 子词 ID 序列 | 模型输入与计费 | 与字符数混用 |
from pathlib import Path
raw = Path("note.txt").read_bytes()
text = raw.decode("utf-8")
print("bytes:", len(raw), "characters:", len(text))
print("first code points:", [hex(ord(char)) for char in text[:5]])
round_trip = text.encode("utf-8").decode("utf-8")
assert round_trip == text
record = {"id": "note-001", "text": text, "encoding": "utf-8"}
print(record["id"], record["text"][:20])代码中的 len(text) 是 Python 字符串层面的长度,不等同于用户看到的字数,也不等同于 Token 数。中文通常一个汉字一个码点,但组合字符、换行和表情会让这种直觉失效。后续做长度预算时,要分别记录字符数、字节数和分词后的 Token 数,并说明统计口径。
保留三份文本,给未来的自己留证据
原始文本用于追溯和重新处理,规范化文本用于确定性规则,Token 序列用于模型输入。三者不要互相覆盖。处理记录还应包含清洗函数版本、编码、时间、来源 ID 和失败原因。这样发现“某类文本的召回率突然下降”时,可以回到原文判断是不是编码、空白或清洗规则改变,而不是先怀疑模型。
如果数据来自用户输入或网页,原文可能含有隐私、脚本或恶意提示。学习项目至少记录来源和授权范围,展示样例时脱敏;生产项目再加入访问控制、内容安全和删除机制。数据工程的第一原则是知道自己保存了什么。
选一句含中文、英文、表情和换行的文本,分别打印 UTF-8 字节数、Python 字符串长度,并用任意分词器记录 Token 数。解释三个数字为什么不同。
7.1.2 清洗、规范化与切分:减少噪声,但不要抹掉任务信号
清洗不是“把所有奇怪字符删掉”。它是为任务定义一套可解释的变换:去除无意义的首尾空白、统一换行、处理重复分隔符、修复明确的编码替换,或者保留表情、标点和大小写作为情感信号。分类、搜索、生成任务对信息的需求不同。先写规则和理由,再实现函数;规则越隐蔽,越难判断模型学到的是内容还是清洗器的副作用。
Unicode 规范化常见 NFC、NFKC 等形式。NFC 主要合并等价表示,NFKC 还可能把兼容字符折叠成更标准的形式。大小写折叠适合不区分大小写的英文搜索,却可能破坏专有名词、代码或缩写。中文分词通常不依赖大小写,但中英文混排、数字和单位仍需要明确边界。对每条规则写一个正例和反例,避免“清洗成功”只是肉眼看起来更整齐。
切句、切段和切词也属于数据变换。按句号切分会遇到小数、网址和缩写;按换行切分会把表格或诗歌的结构打散;按固定字符长度切块会截断句子。分块要为下游任务服务:分类希望样本标签稳定,检索希望每块自洽且带上下文,生成希望保留顺序和边界。所有切分参数都写入配置和 manifest。
| 规则 | 可能收益 | 可能损失 | 验证方式 |
|---|---|---|---|
| strip 空白 | 减少无效前后缀 | 格式任务丢缩进 | 对照原文首尾 |
| 统一换行 | 跨平台一致 | 表格边界改变 | 统计行数/空行 |
| 大小写折叠 | 搜索召回更稳 | 专名、代码混淆 | 专名测试集 |
| NFKC | 兼容字符归一 | 特殊格式被折叠 | 前后差异清单 |
| 去标点 | 简单词频更干净 | 情感、代码、边界丢失 | 任务指标对照 |
| 固定分块 | 长度可控 | 语义被截断 | 块间上下文检查 |
import re
import unicodedata
_whitespace = re.compile(r"[\t\r\f]+")
_many_newlines = re.compile(r"\n{3,}")
def normalize_text(text: str) -> str:
if not isinstance(text, str):
raise TypeError("text 必须是 str")
text = unicodedata.normalize("NFC", text)
text = text.replace("\u0000", "")
text = _whitespace.sub(" ", text)
text = _many_newlines.sub("\n\n", text)
return text.strip()
sample = " 第一行\r\n\r\n\r\n第二行 "
print(repr(normalize_text(sample)))示例只做保守规范化:保留正文标点、大小写和段落边界,不擅自删除信息。规则函数应该是纯函数,输入同样的文本总是得到同样结果,方便单元测试和缓存。若要按任务删除 URL、代码块或 HTML 标签,单独写函数并在名字中体现意图,不要把几十条正则塞进一个“clean”函数。
没有证据支持的删除规则,先不要加入主流程。
清洗函数的易错点
不要用 split() 代替真正的句子切分,因为连续空格、换行和标点会改变结果;不要在训练集和测试集分别拟合清洗统计量,否则预处理本身会泄漏标签或分布信息;不要把错误编码的替换字符 � 当成普通内容静默保留。遇到异常文本时,记录原始 ID 和原始片段,单独进入失败样本表。
清洗版本一旦变化,就应改变 preprocess_version。缓存、词表和向量索引都要知道它依赖哪一版文本。否则你可能用新版规则生成查询,却拿旧版文档向量比较,最终把版本错配误判为模型效果。
选择“去标点”“大小写折叠”或“按句号切分”中的一项,写一个规则看似有益但会伤害任务的文本,并设计一个指标对照。
7.1.3 语料质量与任务定义:先问数据能不能支持结论
文本任务的第一张表不是模型参数表,而是数据字典。它至少描述记录 ID、原始来源、文本字段、标签定义、时间、分组键、许可范围、缺失策略和 split。标签要能被两个人按照同一说明复核;如果“相关”“高质量”“有帮助”没有判定边界,模型指标就无法解释。数据字典可以很短,但每个字段都要有来源和示例。
去重不仅是对文本做 set。完全重复、规范化后重复、近似重复和同一来源的转载都可能造成泄漏。检索任务要防止查询与文档原文重合,分类任务要防止同一用户或同一文章跨 split。先按稳定 ID 和来源分组,再做文本哈希与相似度检查;发现重复时保存处理决定,而不是静默删除。
任务定义要写清输入、输出、决策时点和错误代价。文本分类输出一个标签还是多个标签?检索返回文档还是段落?是否允许“不确定”或“无结果”?正例和负例怎样采样?训练时能看到未来信息吗?这些问题决定 Token、词表、Embedding 和评估指标的设计。模型只是任务合同的一个实现。
| 维度 | 最低检查 | 风险信号 | 产物 |
|---|---|---|---|
| 完整性 | 空文本、异常编码、字段缺失 | 空样本仍进入训练 | quality_report.json |
| 一致性 | 标签映射、语言、格式 | 同标签含义不同 | data dictionary |
| 独立性 | ID/用户/来源不跨 split | 验证异常高 | split audit |
| 重复性 | 精确/规范化/近似重复 | 测试原文出现在训练 | duplicate pairs |
| 覆盖性 | 长度、类别、时间分布 | 线上样本不在训练分布 | histogram/table |
| 授权 | 来源和使用范围 | 无法说明可用性 | source manifest |
from collections import Counter
import hashlib
def audit(records):
seen = set()
labels = Counter()
lengths = []
problems = []
for row in records:
rid, text, label = row.get("id"), row.get("text"), row.get("label")
if not rid or rid in seen:
problems.append((rid, "duplicate_or_missing_id"))
seen.add(rid)
if not isinstance(text, str) or not text.strip():
problems.append((rid, "empty_text"))
continue
if "�" in text:
problems.append((rid, "replacement_character"))
labels[label] += 1
lengths.append(len(text))
fingerprints = [hashlib.sha256(row["text"].encode("utf-8")).hexdigest() for row in records if isinstance(row.get("text"), str)]
return {"count": len(records), "labels": dict(labels), "length_min": min(lengths, default=0), "length_max": max(lengths, default=0), "duplicate_texts": len(fingerprints) - len(set(fingerprints)), "problems": problems}
print(audit([{"id": "a", "text": "文本", "label": "ok"}]))审计报告不是为了让数据看起来“完美”,而是让限制显式可见。若某个类别只有很少样本,报告支持数;若数据来自公开网页,报告抓取时间和许可证;若文本长度超出模型上限,报告截断比例。训练前发现问题的成本最低,训练后才发现标签错位,往往意味着所有曲线都需要重做。
从任务卡到数据卡
任务卡回答“要预测或检索什么、谁使用结果、错误有什么代价”;数据卡回答“数据从哪里来、怎样清洗、有哪些偏差、允许怎样使用”。初学者可以各写半页,附上三条正例、三条负例和三条已知限制。它们会直接指导后续词表、采样、指标和错误分析,而不是文档末尾的装饰。
语言分布和领域分布也要进入数据卡。中文、英文、代码、数字、表情混在一起时,一个统一的清洗规则可能并不公平;不同长度、来源和时间段的样本也可能有不同难度。切分时保留这些分组字段,评估时再按组报告,才知道模型在哪些条件下失效。
为“根据标题检索相关文章”或“判断文本是否包含技术问题”任选一个任务,写出输入、输出、正负例、错误代价、数据来源、切分键、三项限制。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 理解文本对象 | 能解释 str、bytes、编码和 Token 的差别 | 实现分词接口 |
| 写清洗规则 | 每条规则有正例、反例和版本 | 固定预处理 |
| 审计语料 | 能报告空值、重复、分布和泄漏 | 构建词表 |
| 定义任务 | 能写输入、输出、错误代价 | 选择表示与指标 |
02 · TOKENIZATION
Token 与分词:模型如何把一句话切成可处理的片段
模型通常不能直接接收自然语言字符串,而要先把文本切成 Token,再映射成整数 ID。本节比较字符、词、子词和字节级方案,解释特殊符号、分词长度和可逆解码,并写出一个带版本信息的分词器接口。
把 Token 数当作资源预算
同一模型的上下文窗口是有限资源,系统提示、历史对话、检索片段和待生成内容会共同占用它。统计平均值不够,还要看 P95 和最坏样例;否则大多数请求正常,少数长请求会突然截断关键证据。把 Token 预算写进配置并在运行时提示“舍弃了多少内容”,用户才知道答案为什么不完整。
分词规则改变后,任何依赖序列长度的组件都可能受到影响:padding 批次效率、位置编码上限、训练显存、检索块大小和评估切片。版本清单要把这些依赖串起来,不要只更新一个 tokenizer 文件。
分词器的最小测试矩阵
每次修改规则后,固定跑一张小矩阵:普通中文、含标点的中文、英文长词、数字版本、代码路径、空字符串、表情、连续空白和超长输入。对每个样例保存 tokens、ids、长度、特殊位置和 decode 结果。测试矩阵的目的不是证明分词器理解语义,而是防止输入边界在升级时悄悄改变。
| 检查 | 通过条件 | 失败处理 |
|---|---|---|
| 确定性 | 相同文本/版本得到相同 ids | 检查随机或共享状态 |
| 长度 | 长度报告包含特殊符号 | 核对模板和预算 |
| 边界 | offset 不越界且可解释 | 抽样回到原文 |
| 升级 | 差异有 changelog | 重建依赖产物 |
如果差异是有意的,就提高版本并重新生成词表、Embedding 和索引;如果差异不是有意的,就在发布前阻断。分词器是模型输入的源头,源头不稳定,后面的指标都不稳定。
分词器调试的三个视角
第一个视角是表面覆盖:输入的每个字符是否都能映射到某个 Token,是否出现未知符号,中文标点和全角数字是否被意外拆散。第二个视角是长度效率:同一批样本的 Token/字符比、每个 Token 的平均字节数、不同语言的 P50/P95 长度。第三个视角是可逆性:decode 后的文字是否保留必要空格、换行、标点和大小写。三个视角一起看,才能判断分词器是在覆盖、压缩还是丢失信息。
子词合并的直觉可以用一个小语料手算:先把每个词拆成字符,统计相邻对出现次数,把最高频的一对合并,再重复若干轮。合并次数决定词表容量,语料频率决定哪些片段容易复用。真实算法会有边界标记、概率或评分细节,但手算能让你明白“词表不是从字典直接抄来的”,而是从数据分布中学习的压缩方案。
| 样例 | 必须观察 | 潜在回归 |
|---|---|---|
| 空字符串 | 返回空序列或边界符的约定 | 出现非法负长度 |
| 连续空格/换行 | decode 是否恢复任务需要的格式 | 代码块缩进改变 |
| 中英数字 | 边界和 token/字符比 | 单位被拆得过长 |
| 罕见表情 | UNK 或字节回退策略 | 静默丢失符号 |
| 超长文本 | 截断方向、窗口数量、offset | 只保留无关尾部 |
def budget(text, tokenizer, max_length, reserved=4):
pieces = tokenizer.tokenize(text)
usable = max(max_length - reserved, 0)
kept = pieces[:usable]
return {
"characters": len(text),
"tokens": len(pieces),
"kept_tokens": len(kept),
"truncated": len(pieces) > usable,
"discarded_preview": pieces[usable:usable + 8],
}
for text in ["短句", "A very long technical identifier", "混合 API 与中文"]:
print(budget(text, tokenizer, max_length=12))预算函数把保留的特殊位置作为显式参数,而不是在调用处默默减一个数字。若模板以后从 4 个特殊位置变成 6 个,所有长度报告和回归结果都能解释。对滑动窗口,还要返回窗口索引和原文 offset,后续合并检索结果时才不会把同一文本当成多个独立答案。
分词器的性能也值得测量:批量大小、平均 Token 数、P95 Token 数、CPU 时间、内存和是否可以并行。不要只用一条短句做 benchmark;把真实长度分布和最坏样例放进测试。速度优化不能改变 Token 序列,先用固定样例比对结果,再比较耗时。
用一张 Token 账本理解分词器
对每条文本同时记录原文、Token 字符串、Token ID、Token 数和特殊符号位置。这样看到长度突然增加时,可以判断是中文被拆成字符、罕见词退化成很多子词,还是模板额外加入了起止符号。Token 数影响显存、延迟和计费;字符数只描述原文表面长度,不能代替它。
字符级方案的词表小、未知字符少,却可能让长文本变成很长的序列;整词方案可读性强,但新词、拼写变化和中英文混排会产生大量未登录词;子词方案让常见片段复用,同时保留组合能力。BPE 通过合并频繁相邻片段逐步建立词表,WordPiece 也以子词为单位学习词表,Unigram 则从候选片段中选择更合适的分解。它们的训练细节不同,使用时都要把算法、词表和特殊符号一起固定。
特殊 Token 不是普通词。PAD 只负责对齐批次,通常不应贡献池化或损失;UNK 表示词表无法覆盖的输入,出现率过高说明词表或预处理有问题;BOS/EOS 标记序列边界;分类任务可能需要一个聚合位置。给每个符号固定 ID,并在配置中明确它是否参与 attention、loss 和 decode,避免训练与推理使用不同约定。
| 输入现象 | 字符级可能表现 | 子词级可能表现 | 要记录的证据 |
|---|---|---|---|
| 中文短句 | 每字一个单位,长度直观 | 常见词可能合并 | token 数/字符数比 |
| 罕见专名 | 仍可逐字覆盖 | 拆成多片或 UNK | UNK 率、片段边界 |
| 英文长词 | 字符很多 | 词根和后缀可复用 | 平均片段长度 |
| 中英数字混排 | 边界稳定但序列长 | 规则差异明显 | 语言切片统计 |
| 表情/符号 | 可逐码点拆分 | 可能被特殊映射 | 码点与 token 对照 |
def inspect(tokenizer, text):
encoded = tokenizer(text, add_special_tokens=True)
ids = encoded["input_ids"]
pieces = tokenizer.convert_ids_to_tokens(ids)
special = set(getattr(tokenizer, "all_special_ids", []))
return {
"text": text,
"pieces": pieces,
"ids": ids,
"length": len(ids),
"special_positions": [i for i, item in enumerate(ids) if item in special],
}
for sample in ["模型会读中文", "API_v2.0 与 e-mail", "🙂 组合"]:
print(inspect(tokenizer, sample))调试时不要只打印一串 ID。把 Token 还原成字符串片段,并标出特殊位置,才能解释边界。对于带 offset mapping 的分词器,还可以记录每个 Token 对应原文的字符区间,用于高亮、截断和错误定位。若分词器不提供 offset,就不要假装能够精确把模型结果映射回字符。
长度预算应按部署边界倒推:最大序列长度减去系统提示、任务模板、起止符号和可能的生成空间,才是正文可用预算。截断策略有从左截、从右截、头尾保留和按句子截断;检索常常更重视标题和开头,长文档问答可能需要滑动窗口。无论选择哪种策略,都在样例上展示被舍弃的片段,并统计截断率。
版本测试要覆盖 encode→decode 往返、空字符串、全特殊符号、超长输入、Unicode 边界和批量输入。往返不一定恢复完全相同的空白,但应写清允许的差异。词表更新后,旧 ID 不应被新词静默复用;如果必须迁移,生成明确的映射表并重新构建 Embedding。
选三条中文、英文和中英混排文本,分别用字符、词和子词方案编码。设置最大长度 16,展示每种方案的截断位置、特殊符号和最终 mask,并解释哪种方案更适合你的任务。
7.2.1 从字符到 Token:切分单位决定模型看到的世界
Token 可以是一个字符、一个词、一个子词片段,甚至是一到几个字节。不同分词方案在词表大小、序列长度、未知词比例和跨语言能力之间取舍。字符级分词几乎不会遇到未知词,但中文、英文和代码的序列可能很长;词级分词语义直观,却容易因新词、拼写变化和领域术语产生大量未知词;子词通常在这两者之间取得平衡。
“分词”不等于简单的 text.split()。空格在中文里不是可靠边界,标点、数字、网址、代码、表情和混合语言还需要单独处理。现代分词器常把空格信息编码进 Token,或者使用前缀标记来区分词首;因此解码时必须由同一个分词器恢复原文,不能手工把 Token 用空格拼回去。
学习时先观察同一句话在多种方案下的长度和片段。长度影响显存、训练步数和上下文窗口,片段边界影响模型是否容易学到词根、词缀、数字单位和代码符号。不要只问“分得准不准”,还要问“这个任务需要哪些边界信息”“长文本会不会被大量切碎”“多语言文本是否公平”。
没有绝对最优粒度,只有与任务、语料和预算相匹配的选择。
| 方案 | 词表规模 | 序列长度 | 优势 | 代价 |
|---|---|---|---|---|
| 字符级 | 小 | 长 | 几乎无未知词 | 长距离依赖难 |
| 词级 | 大 | 短 | 片段直观 | 新词/拼写变化多 |
| 子词级 | 中 | 中 | 平衡覆盖与长度 | 边界不总直观 |
| 字节级 | 很小 | 可能很长 | 编码覆盖广 | 语义片段被拆细 |
def char_tokens(text):
return list(text)
def whitespace_tokens(text):
return text.split()
def summarize(text, tokenizer):
pieces = tokenizer(text)
return {"text": text, "tokens": pieces, "length": len(pieces)}
samples = ["我喜欢 Python 3.12", "向量检索:top-k", "error: shape=(2, 3)"]
for sample in samples:
print(summarize(sample, char_tokens))
print(summarize(sample, whitespace_tokens))探针程序把“长度”从抽象概念变成可观察数据。正式使用分词器时,还要打印 Token 文本、ID、特殊符号和 decode 结果。对一组长短不同、包含中英文、数字、URL、代码和表情的样本做统计,记录 p50、p95 和最大长度,后续才能合理设置截断与 batch。
分词长度不是字数,也不是模型能力
一个中文字符可能对应一个或多个 Token;一个英文常见词可能是一个 Token,罕见拼写会被拆成多个片段;空格、换行和标点也可能占用 Token。Token 数只是模型计算的离散长度,不能直接当作阅读难度或语义复杂度。比较两个语料时,除了字符数,还要报告 Tokenizer、版本和 Token 长度分布。
长度爆炸会带来平方级注意力成本或更高的推理延迟,但一味追求短序列也会损失信息。对检索文本,可保留标题和上下文;对分类文本,可优先保留与标签有关的片段;对代码,则要保留缩进和符号。截断策略应写在任务卡,而不是隐藏在 DataLoader 里。
准备十条短文本,覆盖中文、英文、数字、URL、代码和表情。用字符列表、空格切分以及一个子词分词器分别统计长度,记录最短、P50、P95 和最长样本。
7.2.2 子词与特殊符号:用有限词表覆盖无限的新文本
子词分词的核心想法是从语料中学习一组常见片段,再用这些片段拼出没有见过的词。BPE 反复合并高频相邻片段,WordPiece 也以子词词表为基础但使用不同的训练目标,Unigram 则从候选片段中选择概率更高的分解。学习这些名字的重点不是背算法细节,而是理解:词表由训练语料决定,分解结果由词表和规则共同决定,换一个 tokenizer 就可能得到完全不同的 ID 序列。
特殊 Token 是序列协议的一部分。常见的有序列开始、序列结束、未知、填充、分隔和掩码符号。它们各自承担不同职责:[PAD] 让不同长度的序列组成 batch,[UNK] 表示词表无法覆盖,[CLS] 或自定义起始符号可作为分类汇聚位置,[SEP] 区分两段文本。特殊符号的 ID 必须固定并写进 manifest,不能每次运行随机分配。
多语言与代码文本会暴露词表的偏差。一个对英文优化的词表可能把中文拆得很碎,对中文优化的词表也可能无法高效表示长英文单词和代码标识符。比较分词器时,按语言、领域和文本类型分组报告长度、未知率和字符覆盖,不要用一条英文句子代表“整体效果”。
| Token | 作用 | 进入 loss 吗 | 常见误用 |
|---|---|---|---|
| PAD | 补齐 batch | 通常用 mask 忽略 | 当成真实词训练 |
| UNK | 未知片段兜底 | 视任务保留 | 默默丢弃原文 |
| BOS/CLS | 序列开头或汇聚 | 依模型目标 | 位置约定不一致 |
| EOS/SEP | 结束或分隔 | 生成任务常需要 | 截断后缺结束符 |
| MASK | 掩码训练占位 | 由目标决定 | 推理时随意加入 |
SPECIAL_TOKENS = ["[PAD]", "[UNK]", "[BOS]", "[EOS]", "[SEP]"]
vocab = {token: index for index, token in enumerate(SPECIAL_TOKENS)}
for token in ["我", "喜欢", "AI", "检索"]:
if token not in vocab:
vocab[token] = len(vocab)
ids = [vocab["[BOS]"], vocab["我"], vocab["喜欢"], vocab["AI"], vocab["[EOS]"]]
assert vocab["[PAD]"] == 0
assert len(set(ids)) == len(ids)
print(vocab)
print(ids)示例用手写词表帮助理解协议,不代表生产分词器的训练过程。真正的 tokenizer 还要保存合并规则、归一化配置、特殊 Token 模板和 decode 逻辑。只保存一个 token-to-id JSON 不足以恢复完整行为;同一词表配不同的预处理或合并规则,编码结果仍可能不同。
形状对齐和语义计数是两件事,不能只依赖 padding 值。
特殊符号的三条纪律
第一,ID 固定:训练、验证、推理和保存加载使用同一映射;第二,语义固定:PAD 不代表空文本,UNK 不代表删除,EOS 不等于截断;第三,统计固定:长度、注意力和 loss 是否包含特殊符号,要在指标定义中说明。若新增一个特殊符号,词表版本、Embedding 行数和 checkpoint 都应一起升级。
检查一条编码结果时,优先打印“Token 文本 + ID + mask”三列。只看 ID 很难发现 BOS/EOS 位置反了、PAD 被放在前面或 SEP 丢失等问题。把这三列保存到小型 JSON 样例,是最便宜的回归证据。
为“单句分类”和“问句+文档检索”分别设计一套特殊 Token,写出它们的顺序、ID、attention mask 和 loss mask。
7.2.3 分词器的可复现接口:encode、decode 和版本必须成对出现
一个可用的分词器接口至少提供 encode(text)、decode(ids)、batch_encode(texts) 和元数据查询。encode 应明确是否加入特殊 Token、是否截断、最大长度和返回哪些 mask;decode 应说明是否跳过特殊 Token、是否恢复空格以及遇到非法 ID 如何处理。接口越模糊,模型输入和人工阅读之间的差异越难解释。
可逆性不是所有任务都要求完全满足,但必须知道哪里不可逆。清洗时删除空白、大小写或 HTML 后,decode 只能恢复规范化文本;子词合并可能恢复语义文本,却不一定保留原始字节。对搜索系统,保存原文和 chunk ID 比追求字节级可逆更重要;对代码生成或法律文本,空格、换行和标点可能必须保留。
分词器资产应与模型权重一起版本化。manifest 里记录算法名称、词表大小、特殊 Token 映射、归一化、最大长度、训练语料范围和文件哈希。加载时检查词表大小是否等于 Embedding 的第一维,检查 PAD/UNK ID 是否与配置一致。任何一项不匹配,都应在启动时失败而不是输出看似合理的结果。
| 接口 | 输入 | 输出 | 必须声明 |
|---|---|---|---|
| encode | 一条 str | ids / mask / offsets | 特殊符号、截断 |
| decode | 一串 int | str | 跳过特殊符号规则 |
| batch_encode | str 列表 | 二维 ids/mask | padding 方向 |
| vocab_size | 无 | 整数 | 是否含特殊符号 |
| metadata | 无 | 版本字典 | 文件哈希与配置 |
class TinyTokenizer:
def __init__(self, vocab, special):
self.vocab = dict(vocab)
self.special = dict(special)
self.inverse = {index: token for token, index in self.vocab.items()}
def encode(self, text, add_special=True):
pieces = list(text)
ids = [self.vocab.get(piece, self.special["unk"]) for piece in pieces]
if add_special:
ids = [self.special["bos"], *ids, self.special["eos"]]
return ids
def decode(self, ids, skip_special=True):
ignored = set(self.special.values())
pieces = [self.inverse.get(index, "�") for index in ids]
if skip_special:
pieces = [piece for piece in pieces if piece not in {"[BOS]", "[EOS]", "[PAD]"}]
return "".join(pieces)
special = {"unk": 1, "bos": 2, "eos": 3}
tokenizer = TinyTokenizer({"[PAD]": 0, "[UNK]": 1, "[BOS]": 2, "[EOS]": 3, "我": 4}, special)
ids = tokenizer.encode("我X")
print(ids, tokenizer.decode(ids))这个微型接口故意使用字符级词表,便于看到未知字符和特殊符号。正式实现应增加 offsets、padding、batch 编码和版本检查,并用固定样例测试 encode→decode、非法 ID、空文本、超长文本以及包含换行的文本。测试通过后再接 DataLoader,能把分词器错误与模型错误分开。
分词器回归样例应该包含什么
至少准备五类样例:普通中文句子、中英文混合、数字和单位、代码/URL、空文本和超长文本。每条样例保存原文、规范化文本、Token 文本、ID、attention mask、decode 结果和 tokenizer 版本。升级分词器后逐条比较,允许的差异要提前写出,不能看到数字变化才临时解释。
offsets 可以把 Token 映射回原文字符范围,是错误分析和高亮检索结果的重要证据。若分词器不提供 offsets,至少保存 chunk 的起止位置和原文 ID。检索系统返回答案时,读者看到的是原文片段,而不是一串无法解释的 ID。
为五类样例记录 encode、decode、长度、特殊符号和版本。故意修改一个词表 ID,观察哪几项测试失败,并写出启动时应该阻止的错误。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 比较粒度 | 能解释字符、词、子词和字节的取舍 | 选择词表 |
| 理解特殊符号 | 能写出 PAD/UNK/BOS/EOS/SEP 的协议 | 组成 batch |
| 使用接口 | 能 encode、decode 并保存版本 | Embedding 查表 |
| 分析长度 | 能报告分组长度分布和长尾 | 设置截断与检索块 |
03 · VOCABULARY & IDS
词表与 ID:把 Token 变成模型可以索引的整数
分词得到的是片段,神经网络需要的是整数 ID。词表负责建立 Token 与整数之间的稳定映射,编码器负责处理未知词、填充、截断和 mask。本节把词表设计成一个可以审计、可以保存、可以加载的工程对象。
词表容量与覆盖率的取舍
词表越大,常见词和专名越容易以较少 Token 表示,但 Embedding 参数量、加载时间和稀有条目也会增加;词表越小,模型能用更少的行覆盖更多组合,却可能让一句话变得很长。不要凭经验宣称一个固定容量适合所有中文技术语料,先画频次曲线和 Token 长度分布,再根据显存、上下文长度和 OOV 需求选择。
特殊符号的顺序一旦发布就应视为不可变接口。可以在 JSON 中同时保存 format_version、tokenizer_version 和 source_corpus_hash,加载时拒绝不匹配的组合。为迁移准备时,生成旧词表到新词表的映射,但不要把无法一一对应的行当作安全迁移;Embedding 权重可能需要重新初始化或蒸馏。
| 曲线/统计 | 它说明什么 | 异常时先查 |
|---|---|---|
| 频次—排名 | 词表是否被少数高频片段占满 | 清洗、采样和域混合 |
| Token/字符比 | 不同语言和长度的压缩效率 | 分词边界、特殊符号 |
| UNK 率 | 词表覆盖未知输入的能力 | 词表版本、输入编码 |
| 有效长度占比 | PAD 和截断的浪费 | batch 策略、max_length |
| ID 分布 | 是否存在异常空洞或冲突 | 序列化和迁移脚本 |
def validate_vocab(payload):
token_to_id = payload["token_to_id"]
id_to_token = {int(k): v for k, v in payload["id_to_token"].items()}
if len(token_to_id) != len(id_to_token):
raise ValueError("双向映射长度不一致")
if set(token_to_id.values()) != set(id_to_token):
raise ValueError("ID 集合不一致")
for token, index in token_to_id.items():
if id_to_token[index] != token:
raise ValueError(f"映射不对称: {token}")
special = payload["special_ids"]
if len(set(special.values())) != len(special):
raise ValueError("特殊 ID 冲突")
if payload["max_length"] <= 0:
raise ValueError("max_length 必须为正")
return True
print(validate_vocab(vocab_payload))验证函数应在模型加载前运行,并把错误写到日志中。特别注意 JSON 的对象键总是字符串,加载 id_to_token 时要显式转换为整数;这是许多“本地能跑、部署报错”的细节。对共享词表的团队,还要在代码评审中检查新增 Token 是否有样例和使用理由。
词表不是字典,而是一份长期契约
一个词表至少包含两个方向的映射:Token 到 ID,以及 ID 到 Token。前者用于编码,后者用于调试、解码和展示。特殊 Token 要在普通 Token 之前或之后采用固定顺序,并把 pad_id、unk_id、bos_id、eos_id 明确写入配置;不要在代码里散落数字 0、1、2。重新排序看似只是保存格式变化,却会让旧 Embedding 的每一行对应到错误 Token。
构建词表时,先只用训练语料统计频次,再按规则选择容量。保留全部高频 Token 可能造成稀有片段太多,容量过小又会让 UNK 率升高。中文项目常把标点、数字和英文片段单独观察;技术文档中 API 名称、版本号和路径往往是任务信号,不应因为频次低就一律删除。词表报告至少包括总容量、特殊符号、覆盖率、UNK 率、最高频和最低频样例。
编码器要把“文本为空”“Token 不在词表”“长度超限”“批次需要补齐”分成不同状态。空文本可以返回只有 BOS/EOS 的序列,也可以作为无效样本拒绝,但必须统一;UNK 是可编码但信息未知;截断是保留一部分信息;PAD 只是为了形成矩阵。把四者都写成一串 0,后续的 mask 和错误分析就无法区分。
| 字段 | 含义 | 验证 | 失败表现 |
|---|---|---|---|
| vocab_version | 词表生成规则和语料版本 | manifest 能回指 | 旧 ID 被误读 |
| token_to_id | 编码方向 | ID 唯一且为整数 | 覆盖或排序不稳定 |
| id_to_token | 解码方向 | 长度等于词表容量 | 显示错误片段 |
| special_ids | PAD/UNK/BOS/EOS 等 | 不与普通 Token 冲突 | mask/loss 错位 |
| max_length | 批次和模型的长度上限 | 截断测试 | 运行时越界 |
| padding_side | 左填充或右填充 | mask 与位置对齐 | 最后 Token 取错 |
def encode_tokens(tokens, token_to_id, special, max_length):
ids = [special["bos_id"]]
ids.extend(token_to_id.get(token, special["unk_id"]) for token in tokens)
ids.append(special["eos_id"])
was_truncated = len(ids) > max_length
if was_truncated:
ids = ids[:max_length]
ids[-1] = special["eos_id"]
mask = [1] * len(ids)
while len(ids) < max_length:
ids.append(special["pad_id"])
mask.append(0)
return {"input_ids": ids, "attention_mask": mask,
"was_truncated": was_truncated}
special = {"pad_id": 0, "unk_id": 1, "bos_id": 2, "eos_id": 3}
vocab = {"文本": 4, "表示": 5, "模型": 6}
print(encode_tokens(["文本", "未知词"], vocab, special, max_length=6))示例把 EOS 放回最后一个有效位置,避免截断后序列没有边界。但这只是一个策略:对生成任务可能需要保留完整前缀,对分类任务可能保留头尾,对检索任务可能保留标题和问题关键词。策略名称写进输出,例如 truncate_policy="head_keep_eos",这样错误分析才知道哪个样本被怎样处理。
词表保存要避免只写一个 Python pickle。JSON、文本或 safetensors 等格式更容易检查和迁移;无论使用哪种格式,都在加载后验证 Token→ID 与 ID→Token 的双射关系、特殊 ID 的存在、最大长度为正、版本字段非空。加载后用同一组固定样例做编码,确保不同机器的结果相同。若词表过大,分片保存也要有总 manifest 和顺序信息。
批处理时除了 attention_mask,还可能需要 token_type_ids、position_ids 或标签 mask。不要因为当前模型用不到就把所有字段混成一个字典而不说明来源。数据集类返回的字段应与模型 forward 参数一一对应,字段名和 dtype 都纳入测试。
手工创建包含 4 个特殊 Token 和 8 个普通 Token 的词表,保存后重新加载。验证双向映射、特殊 ID、三条未知文本、左/右填充和截断后的 EOS 位置。
7.3.1 词表、UNK 与 PAD:一个整数背后必须有固定含义
词表本质上是两个方向的映射:Token 到 ID 的字典,以及 ID 到 Token 的反向表。ID 不是 Token 的自然属性,而是某一版词表中的索引;换一版词表,同一个 Token 可能变成另一个整数。因此词表和模型权重必须绑定保存。训练时 Embedding 的第 17 行代表什么,取决于当时词表的顺序,不能只凭数字猜。
构建词表时通常先统计训练语料中的片段频率,再按频率、覆盖率或最大词表大小选择条目。只用训练 split 统计,避免把验证或测试信息带入词表;如果使用预训练 tokenizer,则把它当作固定外部资产,不要用自己的语料重排 ID。低频片段可以映射到 UNK,但未知率太高说明词表粒度或领域覆盖不足。
PAD 是为了组成矩形 batch,UNK 是为了表达词表无法覆盖的片段,两者不可混用。空文本、缺失文本和纯 PAD 序列也要区分;空文本是数据状态,PAD 是批处理状态。设计词表时先分配特殊 ID,再分配普通 Token,并对每个 ID 写一个不可变的测试。
只保存整数列表而不保存词表,等于丢失了输入协议。
| 字段 | 含义 | 示例 | 不一致后果 |
|---|---|---|---|
| token_to_id | 片段到整数 | “检索”→57 | 模型输入改变 |
| id_to_token | 整数到片段 | 57→“检索” | decode/日志错误 |
| vocab_size | Embedding 行数 | 4096 | 索引越界 |
| unk_id | 未知兜底 | 1 | 未知被误当 PAD |
| pad_id | 补齐占位 | 0 | loss 统计污染 |
| version | 词表行为版本 | vocab-2026-08 | 无法复现 |
from collections import Counter
import json
SPECIAL = ["[PAD]", "[UNK]", "[BOS]", "[EOS]"]
def build_vocab(texts, min_freq=2, max_size=10000):
counts = Counter(piece for text in texts for piece in text)
vocab = {token: index for index, token in enumerate(SPECIAL)}
candidates = [(token, freq) for token, freq in counts.items() if freq >= min_freq and token not in vocab]
candidates.sort(key=lambda item: (-item[1], item[0]))
for token, _ in candidates[: max(0, max_size - len(vocab))]:
vocab[token] = len(vocab)
return vocab, counts
vocab, counts = build_vocab(["文本", "文本", "检索"], min_freq=2)
with open("vocab.json", "w", encoding="utf-8") as handle:
json.dump({"token_to_id": vocab, "special": SPECIAL}, handle, ensure_ascii=False, indent=2)
print(vocab)示例使用字符片段和确定性的排序:频率降序、Token 字典序升序。确定性排序很重要,否则不同 Python 运行或数据遍历顺序可能给同一 Token 分配不同 ID。词表构建函数只读取训练文本,正式项目还要输出频率分布、未知率估计、词表大小和版本哈希。
词表大小不是越大越好
词表变大可以减少拆分和 UNK,但 Embedding 参数量、checkpoint 大小和输入稀疏度也会增加。词表太小则序列变长,训练和推理成本上升。选择大小时同时看覆盖率、平均/长尾 Token 长度、未知率和领域术语保留情况,不能只看训练集频率。
词表排序规则也属于模型协议。频率相同的 Token 若顺序不稳定,模型结果就难以复现。把构建脚本、输入语料版本、min_freq、max_size、特殊 Token 列表和排序规则写入 manifest,未来才能解释一次词表差异。
准备一组包含重复、低频和特殊字符的训练文本,设置 min_freq 与 max_size,输出 Token 频率、ID、未知率估计和词表 JSON。验证四个特殊 ID 固定不变。
7.3.2 编码、解码与 mask:把不同长度的序列组成同一批次
编码通常是 Token→ID 的查表过程,解码是 ID→Token 的反查过程。批处理时还要增加 padding 和 mask:先决定 padding 放在左侧还是右侧,再把每条序列补到当前 batch 的最大长度或固定最大长度。attention mask 表示哪些位置是真实输入,loss mask 表示哪些位置参与目标计算;二者经常相关,却不一定相同。
分类任务常把一个序列映射为一个标签,mask 主要用于注意力或汇聚;语言模型任务通常把输入向右移动一位作为目标,最后一个位置可能没有下一个 Token;序列标注任务则要求每个真实 Token 对应一个标签,PAD 位置必须用 ignore index 或 loss mask 忽略。先画出 input_ids、target_ids、attention_mask 和 loss_mask 的对齐表,再写代码。
解码时不要默认所有 ID 都合法。非法 ID、缺失的反向映射、多个 Token 被错误拼接或特殊符号被跳过,都应有明确策略。日志中打印 Token、ID 和 mask 三列,通常比直接打印一行长整数更容易发现错位。
| 任务 | input_ids | target/loss | PAD 处理 |
|---|---|---|---|
| 句子分类 | 整句 ID | 一个类别 | attention mask |
| 语言模型 | 前缀序列 | 右移一位的 ID | 无目标位置忽略 |
| 序列标注 | 每个 Token | 逐 Token 标签 | label PAD 用 ignore |
| 双句匹配 | SEP 分隔两句 | 一个匹配标签 | mask 标出真实段 |
| 检索编码 | 查询/文档独立 | 相似度或正负对 | 每段分别 padding |
def pad_batch(sequences, pad_id=0, max_length=None):
if not sequences:
return {"input_ids": [], "attention_mask": []}
limit = max_length or max(len(sequence) for sequence in sequences)
input_ids, attention = [], []
for sequence in sequences:
clipped = list(sequence[:limit])
mask = [1] * len(clipped)
padding = limit - len(clipped)
input_ids.append(clipped + [pad_id] * padding)
attention.append(mask + [0] * padding)
return {"input_ids": input_ids, "attention_mask": attention}
batch = pad_batch([[2, 8, 3], [2, 9, 10, 3]])
print(batch)示例采用右侧 padding。左侧 padding 在某些生成场景有用,但会改变位置索引和最后一个真实 Token 的位置,不能只换一个参数。将列表转成 Tensor 前检查每行长度一致、ID 在词表范围内、mask 只包含 0/1;错误应在 collate 阶段暴露。
出现 PAD 不代表四张表都应写成 0;每张表有自己的语义。
mask 的易错点
attention mask 的 0 通常表示不参与注意力,loss mask 的 0 表示不计入损失,不能因为名字相似就复用而不检查目标。若用 ignore_index,确认损失函数真的配置了同一个值;若手工加权,确认分母是有效位置数而不是总 padding 长度。验证一个 batch 时,把每一行的真实长度、PAD 数和有效目标数一起打印。
空文本是最容易暴露边界的样例。加入 BOS/EOS 后仍应有可处理的长度;全 PAD 序列不应让 mean loss 除以零;超长文本截断后要决定是否保留 EOS。把这些样例放入回归测试,避免正常句子掩盖协议错误。
用序列 [BOS, 我, 爱, AI, EOS] 写出语言模型的 input_ids、target_ids、attention_mask 和 loss_mask,说明每个位置为什么计入或忽略。
7.3.3 截断、填充与数据契约:长度预算要和任务一起设计
最大长度是一个工程预算,不是神奇常数。长度太小会截断关键信息,太大则增加显存和延迟。设置前先统计 Token 长度分布,观察中位数、P95、最大值和不同语言/领域的差异;再根据任务决定保留开头、结尾、标题、查询词或滑动窗口。分类通常需要完整关键信息,检索可以分块后分别编码,生成则要保留顺序和特殊边界。
固定长度 padding 易于批处理,却会浪费大量计算;按 batch 动态 padding 更节省,但 batch 顺序和长度分桶会影响随机性和吞吐。对长文本可按 Token 窗口切分并保留 overlap,避免实体或句子正好被截断。每个 chunk 保存原文 ID、起止偏移、窗口编号和是否完整结束,检索结果才可以回到上下文。
数据契约应在进入模型前完成:ID 范围、序列长度、mask 形状、dtype、padding 方向、特殊 Token 顺序和标签格式都可以断言。契约不是为了让代码啰嗦,而是为了让错误靠近源头。训练、验证、推理共用同一套编码器,只有任务允许的增强或截断策略不同。
| 策略 | 适合 | 优点 | 风险 |
|---|---|---|---|
| 固定最大长度 | 简单基线 | 形状稳定 | padding 浪费 |
| 动态 padding | 长度差异大 | 省计算 | 吞吐波动 |
| 长度分桶 | 大批量训练 | 减少填充 | 采样复杂 |
| 滑动窗口 | 长文检索/标注 | 保留上下文 | 重复计算 |
| 首尾拼接 | 只关心两端 | 简单保留重点 | 中间信息丢失 |
def validate_batch(batch, vocab_size, pad_id=0, max_length=128):
ids = batch["input_ids"]
mask = batch["attention_mask"]
if ids.ndim != 2 or mask.shape != ids.shape:
raise ValueError(f"ids/mask shape 不匹配: {ids.shape} vs {mask.shape}")
if ids.shape[1] > max_length:
raise ValueError("序列超过最大长度")
if ids.numel() and (int(ids.min()) < 0 or int(ids.max()) >= vocab_size):
raise ValueError("存在超出词表范围的 ID")
if not ((mask == 0) | (mask == 1)).all():
raise ValueError("attention_mask 必须是 0/1")
return {"batch": int(ids.shape[0]), "length": int(ids.shape[1]), "valid": int(mask.sum())}验证函数把批次当成一个有类型的对象,而不是两块无关 Tensor。正式工程中可以用 dataclass 或 TypedDict 记录字段,让 IDE 和测试都能识别。对动态 padding,返回真实长度或有效 Token 数,训练日志按有效位置聚合;对滑动窗口,记录 overlap,避免评估时把同一原文重复计算却没有说明。
截断后的信息损失要被看见
随机抽取一批超长文本,保存原文、截断前后 Token 数、保留片段和丢弃片段。若标签或答案常出现在被丢弃的部分,模型指标下降不是优化器问题,而是长度策略问题。检索系统还要检查标题、章节名和引用是否被切到相邻 chunk,必要时把元数据前缀复制到每个块。
切块参数一改,所有文档向量都可能失效。把 chunk_size、overlap、tokenizer 版本和 normalize 版本写入索引 manifest;查询端和文档端只要有一项不一致,就在启动时提示。这样索引重建是一次明确的迁移,而不是悄悄得到一批不可比较的向量。
给一个平均 80 Token、P95 260 Token 的技术文档集合,分别为分类和检索任务选择长度策略,写出最大长度、截断/窗口规则和需要记录的字段。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 构建词表 | 能固定特殊 ID、排序和版本 | Embedding 查表 |
| 组成 batch | 能解释 input/mask/target/loss 对齐 | 训练文本模型 |
| 控制长度 | 能用分布选择截断或窗口 | 文档检索 |
| 写数据契约 | 能断言 ID、shape、dtype 和 mask | 评估与回归 |
04 · EMBEDDING
Embedding:从离散 ID 到可以比较的向量
ID 只是索引,不能直接表达“检索”和“搜索”有多相近。Embedding 层把每个 ID 映射到一个可学习的稠密向量,再通过汇聚或上下文模块形成句子表示。本节从查表、padding、平均池化和梯度更新开始,建立向量表示的第一性直觉。
形状检查比“看起来能运行”更可靠
在 Embedding 管道中为词表容量、维度、batch、序列长度和 mask 写断言。批次维度错位时,广播可能让代码运行却把另一条样本的 mask 乘到当前向量;这种错误通常不会立刻报错,只会让指标缓慢变差。记录一次从 [B,T] 到 [B,D] 的形状轨迹,是排查池化问题最便宜的办法。
当你把句向量送入相似度模块,明确是否做归一化、是否停止梯度和是否使用同一个编码器。查询与文档若使用不同的训练模式或预处理,向量空间不再可直接比较。先固定接口,再尝试冻结、微调和投影层。
观察 Embedding 是否真的在学习
固定一小组 Token 和句子,训练前后保存向量快照。可以比较相同类别文本的平均距离、不同类别的最近邻和分类验证指标,但不要只看某两个向量的数值变化。向量空间的质量必须用任务验证;如果训练损失下降而检索 Recall 不变,可能是分类目标与检索目标不一致。
| 观察项 | 正常信号 | 异常时先查 |
|---|---|---|
| padding 行梯度 | 保持固定或不参与更新 | padding_idx 与 mask |
| 损失 | 训练下降、验证不过拟合 | 标签、学习率、split |
| 范数 | 分布有限且无 NaN | 精度、归一化、异常输入 |
| 池化结果 | 加 PAD 不改变有效表示 | mask 广播和分母 |
把快照、配置和 seed 写入实验目录,下一次更换 pooling 或维度时才有对照。维度更大不保证语义更好,先用消融确认增加参数是否值得。
从 Token 向量到句子向量的选择题
如果任务只需要粗略主题,平均池化是透明、便宜、容易调试的起点;如果某些维度的强响应很重要,可以试 max pooling;如果模型专门训练了句首聚合位置,再使用首 Token;如果文本中有长短不一的关键区域,可以加入可学习的 attention pooling。每种方法都要用同一份验证集比较,并把 mask、归一化和维度记录在报告中。
池化前最好先做形状断言。假设 hidden 是 [B,T,D],mask 应能广播为 [B,T,1];分母是每条样本有效位置的数量,不能用整个 T。对全 PAD 或空文本,规定返回零向量、特殊向量还是拒绝,并在下游相似度中处理零向量。边界约定比某一种池化名称更重要。
| 方案 | 保留什么 | 容易失去什么 | 适合先做吗 |
|---|---|---|---|
| mean | 整体平均语义 | 否定、顺序、关键短语 | 最适合基线 |
| max | 每维最强激活 | 出现位置和频率 | 适合对照 |
| first valid | 聚合位置上下文 | 依赖训练约定 | 需确认模型 |
| attention pooling | 可学习重要性 | 解释和过拟合风险 | 基线稳定后 |
| 加权 mean | 明确的规则权重 | 权重设计偏差 | 便于做消融 |
import torch
def masked_mean(hidden, mask):
if hidden.ndim != 3 or mask.shape != hidden.shape[:2]:
raise ValueError("hidden/mask 形状不匹配")
weights = mask.to(hidden.dtype).unsqueeze(-1)
count = weights.sum(dim=1).clamp_min(1)
pooled = (hidden * weights).sum(dim=1) / count
empty = mask.sum(dim=1) == 0
pooled[empty] = 0
return pooled
hidden = torch.randn(2, 4, 6)
mask = torch.tensor([[1, 1, 0, 0], [0, 0, 0, 0]])
print(masked_mean(hidden, mask).shape)如果要微调 Embedding,观察梯度范数和冻结策略。全量训练适合有足够标注的任务;数据很少时,冻结底层并训练轻量分类头可能更稳;领域差异很大时,可以用少量学习率更新。无论哪种策略,都要在报告中记录可训练参数量和学习率。不要仅凭训练损失下降就认为向量空间更适合检索,验证任务才是证据。
把 Embedding 看成一张会被训练的查找表
设词表有 V 个 Token,每个向量维度为 D,Embedding 的权重就是一个 V×D 的矩阵。输入 ID 不是数值大小有意义的特征,而是行号:ID 7 和 ID 8 没有“相邻所以更相似”的数学保证。查表只取出对应行,后续的线性层、注意力或池化才会组合这些向量。这个视角能解释为什么词表 ID 不能随意改,也能解释为什么把 ID 直接喂给线性模型通常是不合理的。
训练时,只有本批次出现的 Token 行会收到主要梯度;长期不出现的稀有 Token 可能仍接近随机初始化。可以通过增加样本、使用子词、设置合适的初始化或冻结/微调策略改善。不要把“向量数值看起来有规律”当成语义证据;Embedding 是否有用,要用分类、检索或相似度任务验证,并与简单基线比较。
padding_idx 的作用是把 PAD 行作为不参与更新的占位符。它不自动替你完成所有 mask:平均池化时仍要用 attention mask 排除 PAD,序列模型也要确认 padding 方向与位置编码一致。若把 PAD 当普通词求平均,短文本会被大量零向量或固定向量稀释,长短样本的比较会产生系统偏差。
| 对象 | 典型形状 | 含义 | 应检查什么 |
|---|---|---|---|
| input_ids | [B, T] | 批次中的 Token ID | 整数 dtype、范围 [0,V) |
| embedding.weight | [V, D] | 每个 ID 的向量表 | V 与词表一致 |
| hidden | [B, T, D] | 每个位置的表示 | PAD 是否被屏蔽 |
| mean pooled | [B, D] | 一条文本的聚合表示 | 除以有效 Token 数 |
| logits | [B, C] | 每个类别的未归一化分数 | 与标签形状对齐 |
import torch
from torch import nn
vocab_size, dim, pad_id = 8, 4, 0
table = nn.Embedding(vocab_size, dim, padding_idx=pad_id)
ids = torch.tensor([[2, 3, 0, 0], [4, 5, 6, 0]])
mask = (ids != pad_id).unsqueeze(-1)
hidden = table(ids)
summed = (hidden * mask).sum(dim=1)
counts = mask.sum(dim=1).clamp_min(1)
sentence = summed / counts
print("weight:", table.weight.shape)
print("hidden:", hidden.shape, "sentence:", sentence.shape)
assert torch.allclose(sentence[0], (hidden[0, 0] + hidden[0, 1]) / 2)用 torch.no_grad() 查看权重时,不要把它误解成模型已经学会了语义;随机初始化的向量也能计算余弦。更有价值的检查是固定 seed 后训练几步,观察损失是否下降,或者让相似文本在验证集上更容易被检索。绘制二维投影只适合帮助观察,不能把投影后的距离当成原空间的精确关系。
池化策略体现了任务假设。平均池化把所有有效 Token 等权相加,稳健但可能冲淡关键否定词;max pooling 保留每个维度的强响应,可能过度依赖一个词;取首位置向量依赖模型是否专门训练了聚合 Token;带权池化可以按注意力或位置加权,但要报告权重来源。先做平均池化基线,再根据错误样例选择更复杂的方案。
设备和 dtype 是工程合同的一部分。词表 ID 通常是 torch.long,Embedding 权重可能是 float32、float16 或 bfloat16;输入和权重在不同设备会报错,混合精度还会影响数值稳定性。每个 batch 进入模型前打印一次形状、dtype、device,确认后再关闭调试日志。很多“模型效果忽高忽低”其实是评估时忘记 model.eval() 或 mask 没有同步移动到 GPU。
用同一批 ID 和 mask,分别实现平均、max 和首个有效 Token 池化。构造一条加入 PAD、另一条不加 PAD 的等价文本,验证两者的句向量是否保持一致。
7.4.1 查表得到向量:Embedding 不是魔法,只是一张可学习的表
可以把 nn.Embedding(vocab_size, embedding_dim) 看成一张形状为 (vocab_size, embedding_dim) 的参数表。输入是整数 ID,输出是对应行的向量。ID 为 7 就取第 7 行,不会自动理解数字大小,也不会把相邻 ID 当成相邻语义。语义结构来自训练目标和上下文,来自许多样本对参数的共同约束。
输入 ID 必须是整数类型,通常是 torch.long;浮点数不能当索引。若输入 shape 是 (B,L),输出就是 (B,L,D);若输入是 (B,),输出是 (B,D)。这条形状规则会贯穿词级分类、序列标注、语言模型和检索编码。先打印 shape,再接后续 Linear 或池化。
padding 行是一个特殊设计。设置 padding_idx 后,该行通常不参与梯度更新,适合表示补齐位置;但它仍然会出现在输出中,所以汇聚时必须使用 mask 排除。不要期待“padding 行是零”就自动解决问题,也不要把真实的空文本 ID 与 PAD 复用。
Embedding 负责查表,句子语义还需要汇聚或上下文建模。
| 输入 | 形状 | 输出 | 检查 |
|---|---|---|---|
| 单个 ID | () | (D,) | long、范围 |
| 批量 ID | (B,) | (B,D) | batch 对齐 |
| 序列 ID | (B,L) | (B,L,D) | Token 轴 |
| 词表 | V | 表第一维 V | 与 vocab_size 一致 |
| padding_idx | 一个 ID | 对应行不更新 | 汇聚需 mask |
import torch
from torch import nn
vocab_size, dimension = 12, 4
embedding = nn.Embedding(vocab_size, dimension, padding_idx=0)
ids = torch.tensor([[2, 5, 0], [3, 4, 6]], dtype=torch.long)
vectors = embedding(ids)
print("ids:", ids.shape, ids.dtype)
print("vectors:", vectors.shape, vectors.dtype)
assert vectors.shape == (2, 3, dimension)
assert embedding.weight.shape == (vocab_size, dimension)
assert torch.equal(vectors[0, 2], torch.zeros(dimension))探针中的零 padding 行是初始化与 padding_idx 的结果,真正使用时仍应依靠 mask 判断有效位置。Embedding 表的参数量是 V×D,词表和维度各翻倍,参数也翻倍。对于大词表或大维度,参数量和显存会成为模型预算的一部分,不能只关注后面的 Transformer 层。
Embedding 的三种误解
第一,向量坐标没有固定人类含义;第 1 维不是“积极”,第 2 维也不是“长度”,除非你通过实验验证。第二,单个 Token 向量不等于句子含义,同一个词在不同上下文中可能需要不同表示。第三,向量距离只有在训练目标、归一化和比较集合确定后才有意义,不能看到两行数字接近就下结论。
读 Embedding 时先问它服务的任务。分类模型的 Embedding 可能只需帮助区分标签,检索模型则需要让同义查询和文档靠近;一个任务中有效的空间,不一定适合另一个任务。保存向量时同时保存 Tokenizer、词表、训练目标和模型版本。
创建一个词表大小 20、维度 8、padding_idx=0 的 Embedding,分别输入 (5,) 和 (2,5) 的 ID,打印输出 shape,并验证 PAD 行的梯度。
(5,8) 与 (2,5,8);反向后检查 embedding.weight.grad[0] 应为零或 None,其他被使用的行可能有梯度。还要检查所有 ID 小于 20。7.4.2 汇聚与训练表示:从一串向量得到一个句子向量
序列 Embedding 的输出包含每个位置的向量,分类或检索通常需要一个固定维度的句子表示。最简单的做法是平均池化:对有效 Token 的向量求和,再除以有效数量。最大池化保留每个维度的最强响应;取首个特殊 Token 作为汇聚位置则依赖模型训练它承担全局信息。不同汇聚方式会产生不同的表示空间,应通过验证实验选择。
平均池化的关键是 mask。直接对包含 PAD 的 (B,L,D) 做 mean(dim=1),短句会被大量零或填充值稀释;正确做法是把 mask 扩展成 (B,L,1),逐元素相乘,再按有效 Token 数归一化。分母至少为 1,空文本要有明确兜底策略。把池化函数独立出来,方便单元测试和替换。
向量表示通过训练目标成形。分类 loss 让同标签样本在决策边界的一侧,检索的对比损失或正负对让相关查询与文档相近,不同任务的梯度方向不同。随机初始化的向量没有稳定语义;训练后要用固定词对、句对或检索样例观察变化,不能把初始化图当作“语义空间”。
| 方法 | 计算 | 优点 | 风险 |
|---|---|---|---|
| masked mean | 有效向量和/有效数 | 简单、稳定 | 词序信息弱 |
| masked max | 有效位置逐维最大 | 突出强信号 | 梯度稀疏 |
| first token | 取首位置 | 接口简单 | 需训练汇聚位 |
| attention pool | 学习位置权重 | 可强调关键 Token | 参数和解释复杂 |
| 加权平均 | 自定义权重求和 | 可纳入词频/质量 | 权重偏差 |
import torch
def masked_mean(vectors, mask):
if vectors.ndim != 3 or mask.ndim != 2:
raise ValueError("需要 vectors=(B,L,D), mask=(B,L)")
if vectors.shape[:2] != mask.shape:
raise ValueError("Token 轴不匹配")
weights = mask.to(vectors.dtype).unsqueeze(-1)
total = (vectors * weights).sum(dim=1)
count = weights.sum(dim=1).clamp_min(1.0)
return total / count
vectors = torch.arange(2 * 4 * 3, dtype=torch.float32).reshape(2, 4, 3)
mask = torch.tensor([[1, 1, 0, 0], [1, 1, 1, 0]])
print(masked_mean(vectors, mask))池化函数的测试要覆盖全长、短序列、全 PAD、一个有效 Token 和不同 dtype。对于全 PAD,返回零向量只是一个选择,也可以抛出异常;关键是把选择写清楚。若向量已经做了归一化,先池化再归一化和先归一化再池化的结果不同,实验报告要记录顺序。
分母错误会让不同长度的句子出现不可比的向量尺度。
训练表示时记录什么
每次训练至少记录 Embedding 维度、词表大小、padding 策略、汇聚方式、loss 定义、正负样本构造和向量归一化。若使用冻结的预训练 Embedding,记录是否允许微调;若只训练分类头,向量空间不会因标签数据改变。冻结与解冻是一个实验变量,不能混在同一运行里。
观察向量变化可以抽取固定 Token 的范数、相似邻居和若干句对的分数。它们不是最终指标,却能帮助发现 Embedding 没有更新、PAD 行被污染或梯度只集中在高频 Token 等问题。
对同一批带 PAD 的序列分别做普通 mean 和 masked mean,列出一个短句的结果差异,说明为什么普通 mean 会改变向量尺度。
7.4.3 位置、设备与形状:Embedding 只是表示链中的一站
Embedding 只知道每个位置的离散片段,不知道 Token 的顺序。把“我喜欢你”和“你喜欢我”做平均池化,可能得到相近甚至相同的向量;如果任务依赖顺序,需要加入位置表示或使用能建模顺序的上下文层。位置 Embedding 可以是可学习表,也可以是固定函数;无论哪种,都要限制最大长度并保证和 Token 表在同一 device。
模型输入的 device 和 dtype 必须一致。ID 通常保留 long,Embedding 输出通常是 float;不要为了统一而把 ID 转成 float。CPU 的词表、GPU 的模型或不同精度的 mask 会造成错误或隐式转换。把 input_ids.device、embedding.weight.device 和输出 dtype 放进 shape probe,是很便宜的保护。
初始化、范数和学习率会影响训练初期的稳定性。Embedding 行的范数过大可能让后续相似度被长度主导,过小则梯度信号弱;对比学习常在比较前做 L2 归一化,分类模型则可能依赖原始尺度。不要跨实验直接比较向量距离,先确认归一化、温度、池化和训练模式完全一致。
| 阶段 | 典型形状 | 新增信息 | 常见错位 |
|---|---|---|---|
| Token ID | (B,L) | 离散片段 | float ID |
| Token Embedding | (B,L,D) | 片段向量 | 词表行数不匹配 |
| 位置表示 | (1,L,D) 或同形 | 顺序 | 超过 max length |
| 上下文表示 | (B,L,D) | 邻近 Token 交互 | mask 未传入 |
| 句子向量 | (B,D) | 固定长度表示 | PAD 污染 |
import torch
from torch import nn
class TokenWithPosition(nn.Module):
def __init__(self, vocab_size, max_length, dimension):
super().__init__()
self.tokens = nn.Embedding(vocab_size, dimension, padding_idx=0)
self.positions = nn.Embedding(max_length, dimension)
self.max_length = max_length
def forward(self, input_ids):
batch, length = input_ids.shape
if length > self.max_length:
raise ValueError("序列超过位置表")
positions = torch.arange(length, device=input_ids.device).unsqueeze(0)
return self.tokens(input_ids) + self.positions(positions)
model = TokenWithPosition(100, 32, 8)
ids = torch.randint(1, 100, (2, 5))
print(model(ids).shape)这个模块只加入 Token 和位置向量,没有注意力或循环上下文;它适合展示 shape 和 device 传播。位置表的最大长度是接口的一部分,超长文本应在分词/切块阶段处理,而不是在模型里静默截断。若采用旋转位置或其他位置方法,仍要记录最大长度和实现版本。
从 Embedding 走向大模型表示
大模型的输入链通常从 Token ID 进入 Token Embedding,再叠加或融合位置表示,经过多层上下文模块,最后得到每个位置或整句的隐藏状态。你现在练习的 shape、mask、device、padding、版本和回归测试,会原样迁移到 Transformer。区别只是上下文模块更复杂、序列更长、资源账本更重要。
不要跳过小模型的表示实验。用几十条文本验证词表、mask、池化和相似度,可以在几秒内发现协议错误;直接上大模型时,同样的错误会被显存、下载和长时间训练掩盖。
构造两句 Token 集合相同但顺序相反的文本,比较平均 Embedding;再加入位置 Embedding,说明哪些信息发生改变、哪些信息仍然没有被建模。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 查表 | 能解释 V×D、ID 类型和输出 shape | 实现池化 |
| 汇聚 | 能用 mask 排除 PAD 并说明分母 | 比较相似度 |
| 位置 | 能解释顺序信息与最大长度 | 文本分类 |
| 训练表示 | 能记录冻结、归一化和训练目标 | 语义检索 |
05 · VECTOR GEOMETRY
相似度与向量:理解“靠近”到底是什么意思
把文本变成向量后,下一步不是立刻建立数据库,而是先理解向量的长度、方向、点积、余弦相似度和距离。只有明确归一化、批次形状、候选集合和阈值,检索分数才有可解释性。
把分数与人工判断放在同一行
抽样复核时同时展示查询、候选、分数、排序位置和人工标签;若两位复核者意见不同,保存争议而不是强行取平均。相似度实验的最终目的,是找到能被任务和证据支持的排序规则,学会解释不确定,而不是把一个阈值变成永远正确的答案。
完成本节后,至少应能手算二维向量、写出稳定余弦函数、处理零向量和空候选,并设计一份含 hard negative 的验证集。
小结:先定义空间,再谈相似
你需要能说清向量维度、归一化、分数方向、候选范围、Top-k、阈值、去重和 tie-break。缺少其中任何一项,“相似度 0.8”都只是一个脱离语境的数字。
给相似度实验加上可复盘的表格
每一条查询—候选对都保存 query_id、candidate_id、文本哈希、模型版本、归一化方式、原始分数、展示分数、相关性标签和人工备注。这样当一个边界样例从 0.71 变成 0.69 时,可以判断是模型更新、精度变化、文本处理还是排序规则造成的,而不是凭截图猜测。
| 字段 | 示例 | 用途 |
|---|---|---|
| query_id/candidate_id | q-03 / c-17 | 定位原文与排名 |
| raw_score | 0.734812 | 保留计算精度 |
| metric | cosine_l2 | 说明分数定义 |
| label | relevant / hard_negative | 连接评估指标 |
| decision | keep / reject / review | 说明阈值结果 |
实验报告还要标出候选池大小和计算成本。相同 Recall 下,减少一半候选或延迟下降 30% 可能比微小的分数提升更有价值。把每次实验的参数和结论写在同一行,未来换成 Transformer 句向量时仍可直接比较。
最后提醒自己:相似度高的文本可能共享模板、标题或关键词,却没有回答同一个问题。把“同主题但方向相反”“同 API 的不同版本”“引用错误结论”加入 hard negative,才能让检索评估接近真实使用。
阈值之外还要决定“返回几条”
Top-k 与阈值解决不同问题:k 限制页面最多显示几条,阈值限制结果最低需要多相关。两者同时使用时,先过滤还是先取 k 会改变结果,必须固定顺序并写入测试。若只有一条结果超过阈值,页面应显示一条并说明候选不足,而不是补上低分内容凑满 k。
| 策略 | 优点 | 风险 |
|---|---|---|
| 只取 Top-k | 结果数量稳定 | 低分噪声也会展示 |
| 只用阈值 | 允许真正无结果 | 结果数量波动 |
| 先阈值后 Top-k | 先保证最低相关 | 需要解释候选不足 |
| 先 Top-k 后阈值 | 计算和页面简单 | 可能错过 k 之后的可接受结果 |
在验证集上同时画 k 和阈值的质量—成本曲线,再选择符合用户场景的组合。缓存查询结果时把这两个参数加入键,不能只缓存查询文本。
用小矩阵手算一次 Top-k
假设一条查询向量是 [1,0],三个候选是 [1,0]、[0.8,0.6] 和 [-1,0]。归一化后,它们的余弦分别接近 1、0.8 和 -1;如果改用未归一化点积,候选长度变大可能改变顺序。先手算这种二维例子,再写批量矩阵代码,能快速发现把转置写反、沿错维度取 Top-k 或把距离当相似度排序的问题。
Top-k 的 tie-break 需要可预测。可以按分数降序、chunk_id 升序排序;如果先用 GPU 的 topk,再对同分结果做稳定排序。展示分数时保留原始浮点用于排序,四舍五入只用于页面;阈值判断也使用未 round 的值。对每次查询记录候选总数、过滤前后数量和最终 rank,才能解释为什么页面只出现两条。
| 样例 | 预期行为 | 断言 |
|---|---|---|
| 查询维度错误 | 在计算前失败 | 错误说明 q/d 维度 |
| 候选为空 | 返回空列表和原因 | 不调用 argmax |
| 零向量 | 拒绝或显式降级 | 无 NaN 分数 |
| 同分候选 | 顺序稳定 | 多次运行 ID 一致 |
| 重复文档块 | 按规则去重/合并 | 保留原始与最终 rank |
def stable_rank(scores, ids, k):
rows = [{"id": item_id, "score": float(score)}
for item_id, score in zip(ids, scores)]
rows.sort(key=lambda row: (-row["score"], row["id"]))
return rows[:k]
scores = [0.8, 0.8, 0.2]
ids = ["chunk-b", "chunk-a", "chunk-c"]
print(stable_rank(scores, ids, k=3))在真实向量检索中,精确矩阵乘法适合小规模实验,近似最近邻索引适合候选很多的场景。换索引算法后,先比较同一批向量的 Recall@k 与延迟,再接入分类或生成模块。近似索引有自己的构建参数、随机性和内存成本,不能只把它当作一个“更快的矩阵乘法”。
相似度可视化可以画分数分布、正负例箱线图或二维投影。图表要标出样本数、切片和归一化策略,不要只展示最容易区分的十个点。若向量都挤在一个小区域,可能是 pooling、训练目标或数据重复造成的塌缩;先查范数和重复,再决定是否改变模型。
相似度只是排序信号,不是事实裁判
向量空间里“靠近”表示模型在训练目标下倾向于给两段文本相似的坐标。它可能受主题、词形、长度、模板和来源影响,并不自动等于事实相同、答案正确或用户满意。比较“如何安装”和“如何卸载”时,词面和主题可能很近,但操作方向相反;比较两段不同版本的 API 时,相似度高也不能证明参数兼容。把相似度作为候选排序信号,再用证据或规则核验,是更稳妥的流程。
点积同时受方向和向量长度影响,适合模型把范数也训练成信号的场景;余弦相似度只看方向,常用于句向量比较;欧氏距离把长度差异也纳入。归一化后,点积与余弦在排序上等价,但归一化前后的阈值不可直接互换。报告中写清是否 L2 normalize、在哪一侧归一化、矩阵乘法的维度和分数的取值范围。
数值边界必须显式处理。零向量无法定义稳定的方向;float16 下很大的点积可能溢出;批量矩阵中一个 NaN 会污染整行排序;候选分数相同却没有 tie-break 会让结果每次顺序不同。工程上可以对范数设置最小值、遇到非有限值拒绝或降级、统一 round 只用于展示而不用于排序,并按 chunk_id 做稳定的次序打破平局。
| 问题 | 先检查 | 推荐记录 | 不要直接做什么 |
|---|---|---|---|
| 分数都很高 | 是否归一化、向量是否塌缩 | 范数分布、分数直方图 | 盲目提高阈值 |
| 排序波动 | tie-break、设备和精度 | 候选 ID、seed、dtype | 只保留一次结果 |
| 空结果过多 | 阈值、查询长度、OOV | 无结果原因 | 降低阈值到没有边界 |
| 近义词不命中 | 训练域、负例、模型目标 | 失败查询对 | 把余弦换成魔法数字 |
| 大批次变慢 | 矩阵形状、索引策略 | 候选数、延迟分位数 | 只看平均耗时 |
import torch
import torch.nn.functional as F
def cosine_scores(query, documents):
query = query.float()
documents = documents.float()
if query.ndim == 1:
query = query.unsqueeze(0)
if documents.ndim != 2 or query.shape[1] != documents.shape[1]:
raise ValueError("向量维度不一致")
q_norm = query.norm(dim=1, keepdim=True)
d_norm = documents.norm(dim=1, keepdim=True)
if torch.any(q_norm == 0) or torch.any(d_norm == 0):
raise ValueError("零向量不能直接计算方向相似度")
q = F.normalize(query, p=2, dim=1)
d = F.normalize(documents, p=2, dim=1)
return q @ d.T
q = torch.tensor([1.0, 0.0])
docs = torch.tensor([[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0]])
print(cosine_scores(q, docs))对批量查询,输出形状通常是 [B, N]:每行是一条查询对 N 个候选的分数。Top-k 应沿候选维度取值,并同时保留原始索引;如果先过滤权限或语言,再排序,k 的含义是“可见候选中的 k”。若业务需要展示不同文档而不是同一文档的多个 chunk,还要在排序后做文档级去重,并报告去重前后的候选数量。
阈值不是模型常数,而是任务决策点。用验证集画分数与相关性分布,选择满足召回、误报或人工审核成本的阈值;测试集只用于最后报告。对于不同查询长度、语言或来源,最优阈值可能不同,但分组阈值会增加维护复杂度,先确认总体阈值是否已经足够。每一次阈值改变都应产生新的配置和回归报告。
硬负例能帮助你发现“高相似但不相关”的边界:同一主题的错误版本、相反操作、相邻章节和只共享模板的文本。把这些样例加入验证或训练要谨慎,不能把测试答案泄漏进模型。相似度实验最终要回到人工可理解的失败类型,而不是只优化一条曲线。
准备 20 对查询—候选,标注相关/不相关,计算余弦分数。尝试三个阈值,分别记录 Recall、误报数、空结果率和最容易被误判的硬负例。
7.5.1 向量几何直觉:长度、方向和坐标都要谨慎解释
向量可以表示为高维空间中的一个点或一条从原点出发的箭头。它的范数描述长度,方向描述各维度相对比例。两个文本向量可能长度相近但方向不同,也可能方向相近但长度差异很大。若任务关注语义方向,通常会先做 L2 归一化;若长度本身携带置信度或频率信息,盲目归一化则可能损失信号。
点积是对应坐标相乘再求和,既受方向影响也受长度影响。余弦相似度是点积除以两个向量的范数,主要比较方向,范围通常在 -1 到 1。欧氏距离比较坐标差异,曼哈顿距离比较绝对差异;没有一种距离天然适合所有表示,必须结合训练目标和数据验证。
高维空间中的“最近”是相对候选集合而言的。把查询与十篇文档比较得到的第一名,不等于与一百万篇文档比较时仍然第一名;向量分布、重复文档、长度和领域都会影响排名。报告检索结果时保存候选 ID、原文、分数、归一化方式和索引版本,才能回溯。
先说明比较空间,再解释分数高低。
| 度量 | 公式直觉 | 受长度影响 | 适合先做什么 |
|---|---|---|---|
| 点积 | 对应维度乘积求和 | 是 | 神经网络打分 |
| 余弦 | 点积/范数乘积 | 较少 | 语义方向比较 |
| 欧氏距离 | 坐标差的平方和开根 | 是 | 同尺度空间 |
| 曼哈顿距离 | 坐标差绝对值求和 | 是 | 稀疏/鲁棒对照 |
| 负距离 | 把距离转成越大越好 | 依距离 | 统一排序接口 |
import torch
x = torch.tensor([[3.0, 4.0], [1.0, 0.0]])
y = torch.tensor([[0.0, 5.0], [0.0, 1.0]])
x_norm = torch.nn.functional.normalize(x, p=2, dim=-1)
y_norm = torch.nn.functional.normalize(y, p=2, dim=-1)
cosine = x_norm @ y_norm.T
dot = x @ y.T
distance = torch.cdist(x, y)
print("norms:", x.norm(dim=-1), y.norm(dim=-1))
print("cosine:\n", cosine)
print("dot:\n", dot)
print("distance:\n", distance)矩阵乘法 x @ y.T 同时计算一个批次中所有查询与候选的点积。若向量已归一化,点积就等于余弦相似度;若未归一化,长向量可能获得不公平优势。计算前检查最后一维相同,比较后保留分数矩阵的行列对应关系,避免把 query 和 document 轴弄反。
不要把坐标轴当成可读特征
Embedding 空间可以整体旋转、翻转或重新参数化而保持相似度关系,单独解释“第 8 维代表科技”通常没有依据。可解释性应来自近邻样本、对照实验、分组指标和错误案例,而不是给某一坐标强行命名。降维图适合展示局部结构,不是对高维空间的完整证明。
向量范数也要单独观察。若某类文本的范数普遍更大,点积排序可能偏向它们;若归一化后排名变化很大,说明长度携带了信号或噪声。把归一化前后的分数、Top-k 重叠率和任务指标一起报告,才能决定哪种比较更合适。
对向量 (3,4) 与 (0,5) 手算点积、范数和余弦相似度,再说明为什么把第二个向量长度扩大后,点积和余弦的变化不同。
7.5.2 余弦与距离:批量计算、Top-k 和阈值都要写清
批量相似度通常把查询矩阵写成 (Q,D)、文档矩阵写成 (N,D),结果是 (Q,N)。第一个轴对应查询,第二个轴对应候选文档;对每一行取 Top-k,才能得到每条查询自己的排名。排序方向取决于度量:相似度越大越好,距离越小越好。统一成一个“分数越大越好”的接口,可以减少下游分支。
Top-k 不是阈值判断。Top-k 总会返回 k 个结果,即使所有候选都不相关;阈值则允许返回“无结果”,但需要验证集选择阈值并考虑分数分布变化。实际系统常先用较大的 k 召回候选,再由更精细的模型或规则重排,最后应用阈值和权限过滤。
数值边界包括零向量、NaN、极大范数、重复候选和空候选集。归一化零向量时要决定返回零向量还是报错;空候选集返回空列表而不是索引越界;分数相同时要有稳定的二级排序,例如文档 ID。确定性排序对回归测试非常重要。
| 字段 | 含义 | 示例 | 易错点 |
|---|---|---|---|
| query_id | 查询身份 | q-001 | 批次下标代替 ID |
| candidate_id | 候选身份 | doc-042 | 向量行号失效 |
| score | 相似度/负距离 | 0.83 | 方向未统一 |
| rank | 候选名次 | 1 | 从 0/1 混用 |
| threshold | 是否可接受 | 0.62 | 在测试集反复调 |
| index_version | 候选快照版本 | idx-v3 | 查询/文档不一致 |
import torch
import torch.nn.functional as F
def topk_cosine(queries, documents, k=3, threshold=None):
if queries.ndim != 2 or documents.ndim != 2 or queries.shape[1] != documents.shape[1]:
raise ValueError("查询和文档必须是 (N,D) 且 D 相同")
if documents.shape[0] == 0:
return []
scores = F.normalize(queries, dim=-1) @ F.normalize(documents, dim=-1).T
limit = min(k, documents.shape[0])
values, indices = scores.topk(limit, dim=-1)
results = []
for row_values, row_indices in zip(values, indices):
row = []
for score, index in zip(row_values.tolist(), row_indices.tolist()):
if threshold is None or score >= threshold:
row.append({"index": index, "score": score})
results.append(row)
return results
q = torch.tensor([[1.0, 0.0]])
d = torch.tensor([[1.0, 0.0], [0.0, 1.0], [0.8, 0.2]])
print(topk_cosine(q, d, k=2))示例把文档行号作为临时 index,真正返回时要映射到稳定的 candidate_id。Top-k 后再按阈值过滤,可能少于 k 个结果;调用方必须支持空列表。若批量查询和文档很多,完整 (Q,N) 矩阵会占用较多内存,可以分块计算,但分块后要做全局 Top-k 合并。
每层都保存候选 ID,方便解释为什么某文档最终出现。
阈值不是模型的永久属性
阈值与分词器、Embedding 模型、语料领域和索引分布绑定。模型升级或文档增加后,分数分布可能改变,旧阈值不能直接沿用。用独立验证集选择阈值,报告 precision/recall 变化和无结果率;上线后监控分数分布漂移。
权限和业务规则应在候选过滤层明确处理。一个相似度最高但用户无权查看的文档不应被返回;过滤后结果不足 k 时,接口要能返回少量或无结果,而不是补回不合规候选。
写一个函数输入查询向量、文档向量、文档 ID 和阈值,返回按分数降序、ID 升序的结果。测试空候选、k 大于文档数、零向量和没有结果四种情况。
7.5.3 相似度实验:用小样本验证表示空间是否有用
相似度实验不需要一开始建立百万级索引。先准备几十条文档和查询,人工标注相关、部分相关和不相关,记录同义改写、关键词重叠、否定句、数字差异和跨领域样例。用不同的 tokenizer、池化、归一化和模型生成向量,比较 Top-k 重叠、相关率、无结果率和错误类别。
对照实验要固定候选集合和文档 ID。若只更换 Embedding 模型,清洗、切块、查询、Top-k 和阈值保持一致;若更换切块策略,则要重建文档向量,并标记 index_version。结果表不仅写平均分数,还要展示一条成功检索、一条相似但错误、一条完全找不到的案例。
向量可视化可以帮助提出假设。用 PCA、随机投影或其他降维方法画出类别和近邻,观察同义句是否聚在一起、长文本是否因长度聚类、某一领域是否形成孤岛。降维图不是指标,最终仍要回到人工标注和任务评估。可视化的价值是告诉你下一步该看哪一批错误。
| 变量 | 固定项 | 记录指标 | 示例结论 |
|---|---|---|---|
| Tokenizer | 模型/块/候选 | 长度、召回 | 中文长尾变短 |
| Pooling | Tokenizer/模型 | Top-k、分数 | 标题权重影响排名 |
| Normalize | 其余不变 | 排名重叠率 | 点积偏向长向量 |
| Chunk size | 查询/模型 | Recall@k、无结果率 | 块太短缺上下文 |
| 阈值 | 候选与分数 | 精确率、覆盖率 | 阈值提高减少噪声 |
def recall_at_k(results, relevant, k):
relevant = set(relevant)
if not relevant:
return 0.0
hits = sum(item in relevant for item in results[:k])
return hits / len(relevant)
def mean_reciprocal_rank(results, relevant):
relevant = set(relevant)
for rank, item in enumerate(results, start=1):
if item in relevant:
return 1.0 / rank
return 0.0
cases = [
(["doc-a", "doc-b", "doc-c"], ["doc-b"]),
(["doc-x", "doc-y"], ["doc-z"]),
]
for results, relevant in cases:
print(recall_at_k(results, relevant, 2), mean_reciprocal_rank(results, relevant))指标函数要先定义 relevant 的粒度:相关的是文档、段落还是答案证据?如果一个文档有多个相关 chunk,命中其中一个是否算成功?不同定义会产生不同 Recall@k。把标注规则、k、候选去重和无关样本写入评估说明,避免为了得到好看的数字临时改变口径。
相似度失败的四个常见原因
一是文本清洗把关键否定或数字删除;二是切块把标题和正文拆开;三是池化把 PAD 或长文本噪声混入;四是候选索引与查询模型/版本不一致。排查时先选一条失败查询,打印原文、Token、向量范数、Top-k ID 和分数,再逐层比较成功样例。
如果相似度高但人工认为不相关,检查关键词共现、模板化文本和重复来源;如果相似度低但语义明显相关,检查同义表达、领域术语和负例覆盖。错误类别比单纯增加维度更能指导下一次数据或模型改动。
准备三条查询,各自列出人工相关文档、Top-3 结果、分数、是否命中和错误类型。选择一次只改变池化或切块的实验,说明预期影响。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 理解几何 | 能解释范数、点积、余弦和距离 | 批量排序 |
| 实现 Top-k | 能处理批次、阈值、空集和稳定排序 | 向量索引 |
| 设计实验 | 能固定候选、标注相关性和记录版本 | 评估检索 |
| 复盘错误 | 能回到原文、Token、向量和索引 | 端到端项目 |
06 · TEXT CLASSIFICATION
文本分类:把表示、数据和训练循环接在一起
文本分类是检验表示是否有用的第一块实验场。我们用统一的 Token、词表和 mask 读取短文本,先做多数类与词袋基线,再训练 Embedding 平均池化分类器,最后分析类别不平衡、阈值和错误样例。
训练一轮之前,先画出标签和错误的边界
标签编码表应同时保存人类可读名称、整数 ID、定义和示例。不要按字母顺序临时生成 ID 后直接训练;新增类别、类别合并或标签改名都可能改变旧报告。多标签任务使用每个标签独立的 sigmoid 与二元损失,单标签互斥任务使用 softmax 语义的交叉熵,二者的数据格式和阈值策略不同。
学习率、batch size、epoch、class weight、dropout、seed 和 early stopping 都属于实验配置。训练日志至少记录每轮训练损失、验证损失、主要指标和最佳 checkpoint;只保存最后一轮,可能错过验证集表现最好的模型。测试集只在最终选择方案后运行,避免把测试结果当成调参反馈。
| 现象 | 先看 | 可能修复 |
|---|---|---|
| 训练/验证都低 | 标签定义、输入形状、基线 | 修数据或任务 |
| 训练高、验证低 | 重复、数据量、正则化 | 去重、扩充、降复杂度 |
| 多数类很高 | 每类 recall、混淆矩阵 | 权重、采样、补少数类 |
| 某长度桶差 | Token 长度、截断、padding | 调整预算/分桶 |
| 数字版本错 | Token 化、规范化、标签样例 | 增加技术 hard cases |
from collections import defaultdict
def error_buckets(rows):
buckets = defaultdict(list)
for row in rows:
if row["gold"] == row["pred"]:
continue
text = row["text"]
if len(text) < 12:
key = "too_short"
elif any(char.isdigit() for char in text):
key = "contains_number"
elif "不" in text or "未" in text:
key = "negation"
else:
key = "other"
buckets[key].append(row["id"])
return {key: {"count": len(ids), "ids": ids}
for key, ids in buckets.items()}
print(error_buckets(prediction_rows))错误分桶只是起点,不要把一个规则标签当成根因。抽取代表样例后,判断问题来自输入缺信息、标签边界、模型表示、阈值还是展示。每个修复动作都应该对应一组回归样例,下一次训练才能判断它是否真的减少了该类错误。
先把分类器当作一个输入输出合同
一个单标签分类器接收批量 input_ids 和可选的 attention_mask,输出形状为 [batch_size, num_classes] 的 logits。标签是形状 [batch_size] 的整数类别索引,而不是 one-hot 文本。CrossEntropyLoss 内部会处理 log-softmax 和负对数似然,因此训练时传 logits,不要先手动 softmax 再传入。预测阶段才用 softmax 或 argmax 解释概率和类别。
先写多数类、随机、关键词和词袋基线,能够回答“任务是否比猜测更难”。如果 Embedding 平均池化只比多数类高一点,不一定是代码错,也可能是样本太少、标签含糊、文本截断或任务本身需要上下文。基线还可以作为回归护栏:任何复杂模型都不应在同一 split 上明显输给一个合理的简单方案却没有解释。
训练循环要分清训练模式和评估模式。训练时清零梯度、前向、计算损失、反向和更新;验证时使用 model.eval() 与 torch.no_grad(),不能把验证样本加入梯度。固定 seed 只能减少随机波动,不能保证所有 GPU 算子绝对确定;报告中写出设备和确定性设置。
| 对象 | 形状/类型 | 规则 | 典型错误 |
|---|---|---|---|
| input_ids | LongTensor [B,T] | 每个值在词表范围 | float 或越界 ID |
| attention_mask | Long/Bool [B,T] | PAD 为 0,有效为 1 | 与 ids 长度不同 |
| labels | LongTensor [B] | 范围 [0,C) | one-hot 传给 CE |
| logits | Float [B,C] | 未 softmax 的分数 | 先 softmax 再求 CE |
| prediction | LongTensor [B] | argmax 或阈值决策 | 把概率当标签 |
import torch
def train_one_epoch(model, loader, optimizer, loss_fn, device):
model.train()
total_loss, total_items = 0.0, 0
for batch in loader:
ids = batch["input_ids"].to(device)
mask = batch["attention_mask"].to(device)
labels = batch["labels"].to(device)
optimizer.zero_grad(set_to_none=True)
logits = model(ids, mask)
loss = loss_fn(logits, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * labels.size(0)
total_items += labels.size(0)
return total_loss / max(total_items, 1)
@torch.no_grad()
def predict(model, loader, device):
model.eval()
outputs = []
for batch in loader:
logits = model(batch["input_ids"].to(device), batch["attention_mask"].to(device))
outputs.extend(logits.argmax(dim=1).cpu().tolist())
return outputs类别不平衡时,可以在损失中使用 class weight、在采样器中提高少数类概率,或收集更有代表性的样本。三者都可能改变训练分布,所以验证和测试仍应保持真实分布。阈值调节尤其适合二分类或多标签任务,不能为了提高单一 F1 而让线上误报成本失控。
错误分析要回到文本而非只看 confusion matrix。把错误按“否定词、数字/版本、过短、过长、混合语言、标签边界、近似重复、无上下文”分桶,抽取每桶的支持数和代表样例。若某个桶很大,先补数据或改任务说明;若只是少数异常,记录已知限制可能比增加模型层数更诚实。
在固定 split 上运行多数类、关键词规则、词袋和平均 Embedding 四个方案,记录 accuracy、macro-F1、每类支持数和训练/推理时间。挑选三个错误样例说明差异来源。
7.6.1 分类数据与基线:先证明任务本身能被学习
一个分类样本通常包含文本、标签、稳定 ID 和可选元数据。标签可以是单标签、多标签、序数或连续分数;本节先处理单标签多分类,标签用从 0 开始的整数索引。训练、验证和测试要遵守独立性,类别映射只由训练配置定义,不能在每次 split 中重新排序。
基线的作用是给复杂模型一个参照。多数类基线回答“什么都不看时能达到多少”,词袋或 TF-IDF 线性模型回答“关键词和频率已经能解释多少”,Embedding 平均池化模型再加入可学习的分布表示。如果复杂模型只比多数类高一点,先检查数据和标签;如果词袋已经很强,下一步应分析语序、否定和长距离依赖,而不是盲目加层。
分类 Dataset 的输出要与 DataLoader 和模型接口一致。建议返回 input_ids、attention_mask、label 和 id;collate 负责动态 padding,模型负责汇聚和 logits,训练循环负责 loss。每一层只做自己的事情,错误就能定位到数据、表示或优化。
每个箭头都应有 shape、dtype 或分母证据。
| 基线 | 输入信息 | 优点 | 看不见什么 |
|---|---|---|---|
| 多数类 | 标签频率 | 最快、下限清楚 | 任何文本内容 |
| 规则 | 关键词/正则 | 可解释 | 同义与组合语义 |
| 词袋线性 | 词频/TF-IDF | 强且便宜 | 词序和上下文 |
| 平均 Embedding | Token 向量汇聚 | 端到端可学习 | 复杂语序 |
| 上下文模型 | 序列交互 | 表达力强 | 资源与解释成本 |
from torch.utils.data import Dataset
class TextDataset(Dataset):
def __init__(self, rows, tokenizer, label_to_id):
self.rows = list(rows)
self.tokenizer = tokenizer
self.label_to_id = dict(label_to_id)
def __len__(self):
return len(self.rows)
def __getitem__(self, index):
row = self.rows[index]
if row["label"] not in self.label_to_id:
raise KeyError(f"未知标签: {row['label']}")
return {
"id": row["id"],
"input_ids": self.tokenizer.encode(row["text"]),
"label": self.label_to_id[row["label"]],
}
rows = [{"id": "a", "text": "安装失败", "label": "bug"}]
print(TextDataset(rows, tokenizer=TinyTokenizer, label_to_id={"bug": 0})[0])示例假设外部提供一个具有 encode 方法的 tokenizer;实际运行时应传入实例而不是类。Dataset 不应在每次取样时重新建立词表或读取验证标签。调试时先取一个样本,确认 ID、标签和原始记录一一对应,再接 DataLoader。
分类数据的最低审计
统计每类样本数、文本 Token 长度、空文本比例、重复 ID、重复文本和 split 交集。随机抽取每类若干条,检查标签是否符合定义;尤其关注否定、反讽、代码、数字和边界案例。类别极不平衡时,先报告多数类基线和每类支持数,再决定加权损失或采样。
标签映射要有方向和版本:label_to_id 供训练,id_to_label 供报告。不要在评估脚本里按字母序重新生成映射,否则混淆矩阵的列名可能错位。把映射写进 manifest,加载 checkpoint 时检查类别数一致。
为一个二分类文本集合计算多数类基线,写一个关键词规则,再训练一个平均 Embedding 线性头。固定 split 和 seed,比较 accuracy、macro recall 和错误样例。
7.6.2 训练分类器:把 logits、CrossEntropyLoss 和指标对齐
多分类模型输出 (B,C) 的 logits,标签是 (B,) 的 long 索引。CrossEntropyLoss 内部包含对 logits 的对数 Softmax 和负对数似然,因此训练前不要手动 Softmax。训练循环的顺序与上一章一致:设置 train、取 batch、搬到 device、zero_grad、forward、loss、backward、step;评估时切换 eval 并关闭梯度。
指标计算要保存正确数和总数,而不是把每个 batch 的准确率简单平均。对于类别不平衡,accuracy 之外要算宏平均召回或 F1;加权损失、重采样和阈值调整都只用训练/验证信息选择。测试集在配置冻结后只运行一次,避免把测试指标变成调参信号。
训练初期最有效的检查是小批次过拟合。取 16~32 个样本,确认 loss 能下降、预测能记住样本、Embedding 与分类头参数发生变化。若小批次无法过拟合,优先检查 mask 汇聚、标签索引、学习率、梯度和模型模式。复杂文本任务也可以先用字符级 tokenizer 验证训练循环,再换子词。
| 对象 | shape/dtype | 送入 | 错误表现 |
|---|---|---|---|
| input_ids | (B,L) long | Embedding | 索引/设备错误 |
| mask | (B,L) 0/1 | 池化 | PAD 污染 |
| pooled | (B,D) float | 分类头 | Linear 维度错 |
| logits | (B,C) float | CrossEntropyLoss | 误传概率 |
| labels | (B,) long | loss/指标 | 类别越界 |
import torch
from torch import nn
class AverageTextClassifier(nn.Module):
def __init__(self, vocab_size, dimension, classes, pad_id=0):
super().__init__()
self.embedding = nn.Embedding(vocab_size, dimension, padding_idx=pad_id)
self.head = nn.Linear(dimension, classes)
def forward(self, input_ids, attention_mask):
vectors = self.embedding(input_ids)
weights = attention_mask.to(vectors.dtype).unsqueeze(-1)
pooled = (vectors * weights).sum(dim=1) / weights.sum(dim=1).clamp_min(1.0)
return self.head(pooled)
model = AverageTextClassifier(100, 32, 2)
ids = torch.randint(1, 100, (4, 8))
mask = torch.ones(4, 8, dtype=torch.long)
labels = torch.tensor([0, 1, 0, 1])
logits = model(ids, mask)
loss = nn.CrossEntropyLoss()(logits, labels)
loss.backward()
print(logits.shape, float(loss))代码把汇聚和分类头放在同一模块,适合入门;正式工程可以把 Encoder 和 Head 分开,便于复用向量做检索。注意 weights.sum 的分母是每条样本有效 Token 数,不能用一个 batch 的总数。标签 dtype 和类别范围在 Dataset/collate 阶段就应被验证。
每个证据点都可以单独做测试或日志。
学习率和损失的观察
训练 loss 快速下降而验证 loss 上升,可能是过拟合、切分泄漏或验证 transform 不一致;训练和验证都不动,可能是标签、mask、学习率或参数未注册;loss 变成 NaN,先回到输入和梯度的有限性检查。记录第一批 logits、第一步梯度和每个 epoch 的有效样本数,通常比只看最后一轮更有用。
优化器应在创建模型后一次性接收参数。若冻结 Embedding,确认 requires_grad=False 且没有把它误传给需要更新的优化器;若解冻,记录解冻 epoch 和学习率。模型、词表、标签映射和训练配置共同决定结果。
选 16 条文本,训练平均 Embedding 分类器,记录每轮 loss、训练准确率、Embedding 范数和分类头梯度。若无法接近 100% 训练准确率,按数据→表示→优化顺序排查。
7.6.3 类别与错误:不要让一个总体数字替你解释模型
类别不平衡时,模型预测多数类也可能获得不错的 accuracy。混淆矩阵、每类 precision/recall、宏平均和支持数能揭示少数类是否被忽略。加权 CrossEntropyLoss 可以提高少数类的损失权重,WeightedRandomSampler 可以改变采样分布,但二者会影响概率校准和训练噪声,必须用验证集比较。
文本分类错误通常与长度、领域、标签边界、否定、拼写和词表未知率有关。把错误样本保存为结构化 JSON,字段包括 ID、原文、真实标签、预测标签、概率、Token 数、模型/词表版本和错误桶。人工复盘时先看高置信度错误,再看每类最常见混淆,往往能发现标签定义或清洗规则的问题。
阈值只适用于需要选择接受/拒绝边界的场景。二分类 logits 可以在验证集上选择阈值,报告 precision-recall 曲线和无结果率;多分类通常保留最大概率和置信度,但不要把未经校准的 Softmax 当作可靠概率。测试集不参与阈值搜索,发布后要监控输入长度、类别分布和置信度漂移。
| 错误桶 | 观察字段 | 可能原因 | 下一步 |
|---|---|---|---|
| 少数类漏检 | 真实类/支持数 | 不平衡、阈值 | 加权/采样对照 |
| 高置信度错 | 概率/Token | 分布外、标签噪声 | 校准/补数据 |
| 短文本错 | 长度/字符 | 上下文不足 | 保留元数据 |
| 领域集中错 | 来源/主题 | 覆盖不足 | 分组评估 |
| 否定句错 | 原文/Token | 汇聚丢顺序 | 上下文模型 |
from collections import defaultdict
def confusion(targets, predictions, classes):
matrix = [[0 for _ in classes] for _ in classes]
for truth, prediction in zip(targets, predictions):
matrix[truth][prediction] += 1
return matrix
def per_class_recall(matrix):
recalls = {}
for index, row in enumerate(matrix):
support = sum(row)
recalls[index] = row[index] / support if support else 0.0
return recalls
def error_buckets(rows):
buckets = defaultdict(list)
for row in rows:
key = f"{row['target']}->{row['prediction']}"
buckets[key].append(row)
return {key: len(value) for key, value in buckets.items()}
matrix = confusion([0, 0, 1, 1], [0, 1, 1, 0], classes=[0, 1])
print(matrix, per_class_recall(matrix))混淆矩阵的行列方向必须写在图和报告中。上面的实现按真实标签作行、预测标签作列;如果你的可视化库采用相反约定,转换时保留原始矩阵和代码版本。宏召回是每类召回的简单平均,不是把所有样本合并后再算。
把错误变成数据改进假设
如果错误集中在标签边界,先重写标注指南并抽样复核;如果集中在未知 Token,检查词表或领域数据;如果短文本缺少上下文,比较拼接标题、字符级表示或上下文模型;如果高置信度错误来自模板,检查重复和泄漏。每个假设只改一个因素,保留旧模型和旧报告。
改进后的指标必须和旧版本使用同一验证集、同一类别映射和同一分母。若新增数据或改变切分,报告应明确这是新实验,不要把数字直接放在旧曲线旁边当作同一条件下的提升。
从分类结果中挑一个召回最低的类别,列出支持数、最常见混淆、三条原文和一个只改变采样或损失权重的实验方案。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 搭建分类数据 | 能返回 ID、input_ids、mask 和 label | 训练 baseline |
| 对齐 loss | 能解释 logits、long 标签和 reduction | 加入指标 |
| 做小批次验证 | 能过拟合 16~32 条样本 | 扩大训练 |
| 分析类别 | 能读混淆矩阵、宏召回和错误桶 | 语义检索 |
07 · SEMANTIC RETRIEVAL
语义检索:从文档切块到可解释的 Top-k
语义检索把查询和文档映射到同一个向量空间,再按相似度返回候选。本节从文档切块、元数据和向量索引开始,说明召回、重排、权限过滤与结果解释,完成一个不依赖黑盒的最小检索闭环。
两阶段检索要分别回答两个问题
第一阶段问“相关内容有没有进入候选池”,第二阶段问“候选中谁应该排在前面”。如果第一阶段就漏掉了相关 chunk,重排模型再强也无法补回;如果候选池很大但排序差,用户会看到噪声。分别记录 candidate_recall、rerank_mrr、候选数量和每阶段耗时,才能知道瓶颈是召回还是排序。
查询和文档的编码模板要成对设计。查询可以加入“问题:”,文档可以加入标题和段落边界;模板改变会改变向量分布,必须进入模型/预处理版本。对技术资料,标题、API 名称和版本号常常值得额外权重,但权重应在验证集上调,而不是凭一条成功案例固定。
| 阶段 | 字段 | 解释用途 |
|---|---|---|
| 查询 | query_id、raw_text、normalized_text、user_acl | 重现输入和权限 |
| 召回 | candidate_count、semantic_scores、latency_ms | 判断覆盖与成本 |
| 重排 | features、weights、rerank_scores | 解释顺序变化 |
| 过滤 | filtered_count、filter_reasons、policy_version | 解释空结果 |
| 展示 | rank、chunk_id、source、offset | 回到证据 |
def trace_query(query_id, query, candidates, visible_acl, threshold):
trace = {"query_id": query_id, "candidate_count": len(candidates), "filtered": []}
visible = []
for item in candidates:
if not set(item["acl"]).intersection(visible_acl):
trace["filtered"].append({"id": item["chunk_id"], "reason": "acl"})
continue
if item["score"] < threshold:
trace["filtered"].append({"id": item["chunk_id"], "reason": "threshold"})
continue
visible.append(item)
visible.sort(key=lambda item: (-item["score"], item["chunk_id"]))
trace["results"] = [
{"rank": rank, "chunk_id": item["chunk_id"], "source": item["source"]}
for rank, item in enumerate(visible, start=1)
]
return trace
print(retrieval_trace("q-01", "如何编码", candidates, {"public"}, 0.6))日志中的过滤理由应是有限、稳定、对用户安全的类别,例如 acl、threshold、duplicate、invalid_metadata。不要把“这篇内部文档与查询很相似”写进无权限用户可见的错误消息。调试日志可以更详细,但也要遵守脱敏和保留期限。
结果解释最好同时展示一个语义信号和一个词面信号:相似度告诉用户为什么进入候选,关键词命中或标题字段告诉用户为什么排序靠前。两个信号冲突时,不要自动选择看起来更合理的一个,而是保留分数和规则,交给评估与人工复核。可解释性是让错误变得可定位,不是给模型编造理由。
索引的核心是“能找到,也能回去”
向量索引并不只是一个矩阵。它还要保存每一行向量对应的 chunk ID、文档 ID、标题、来源、字符 offset、权限、语言和版本。矩阵顺序一旦改变,metadata 必须同步重排;否则相似度算对了,展示出来的正文仍可能来自另一篇文档。学习阶段可以把向量和 metadata 放在同一个 JSON/NPY 配对目录,加载时先检查数量和 ID 顺序。
切块大小会在召回率、证据完整性和延迟之间折中。块太小,查询命中后缺少上下文;块太大,主题混杂且向量被多个意图平均。重叠窗口可以减少边界丢失,但会制造重复候选和更大的索引。先用段落切块建立基线,再用固定 Token 窗口、标题拼接和 overlap 做单变量对照,所有参数进入 manifest。
召回阶段优先追求覆盖,重排阶段再追求顺序。第一阶段可以用近似向量搜索、BM25 或词袋快速取较大的候选池;第二阶段把查询和候选一起交给更精细的交互模型或规则。两阶段的分数不在同一尺度时,要先归一化或在验证集学习权重,不能直接把 0.8 的余弦和 12.0 的关键词计数相加。
权限过滤的顺序要符合安全目标:用户无权查看的文档不能因为高分先出现在页面或日志。如果索引支持预过滤,记录过滤条件;如果只能后过滤,扩大召回池并统计被过滤数量,避免所有可见结果都被挡掉。没有权限字段的旧 chunk 要默认不可见,而不是默认公开。
| 一致性 | 校验内容 | 出错后表现 |
|---|---|---|
| ID 一致 | 向量行与 chunk_id 一一对应 | 分数正确、正文错误 |
| 版本一致 | 模型、词表、清洗和索引相互匹配 | 同查询结果不可复现 |
| 权限一致 | ACL 与当前用户策略一致 | 越权或空结果 |
| 排序一致 | 分数、tie-break、去重规则固定 | 刷新后顺序变化 |
| 证据一致 | offset 能回到同一原文 | 高亮错位、无法核验 |
import hashlib
import json
from pathlib import Path
def make_manifest(chunks, model_name, preprocess_version):
ids = [row["chunk_id"] for row in chunks]
if len(ids) != len(set(ids)):
raise ValueError("chunk_id 必须唯一")
return {
"index_version": "index-v1",
"model_name": model_name,
"preprocess_version": preprocess_version,
"chunk_count": len(chunks),
"chunk_order_hash": hashlib.sha256(
"\n".join(ids).encode("utf-8")
).hexdigest(),
"dimensions": chunks[0]["vector_dim"] if chunks else 0,
}
chunks = [{"chunk_id": "c-1", "vector_dim": 384}]
Path("index_manifest.json").write_text(
json.dumps(make_manifest(chunks, "demo-embedding", "clean-3"),
ensure_ascii=False, indent=2),
encoding="utf-8",
)查询日志也要分层保存:原始查询、规范化查询、Token 数、候选池大小、召回耗时、重排耗时、最终结果 ID、空结果原因和用户权限版本。日志中不要直接保存敏感正文,必要时使用哈希和短片段。这样可以计算 P50/P95 延迟、定位慢查询,并区分“没有相关文档”和“有相关但被权限过滤”。
去重有两个层次:相同 chunk_id 的重复属于程序错误;同一文档相邻重叠块的重复属于策略问题。可以先按文档取最高分,再用相邻 offset 合并展示,但评估时要保留原始 rank,避免把后处理的漂亮页面误当成召回能力。对标题和正文混合打分时,记录每个信号,方便解释重排。
为 12 个 chunk 建立向量和 metadata,故意让一个 chunk_id 重复、一个向量维度错误、一个版本字段为空。编写加载检查并说明每个错误会在页面上造成什么后果。
7.7.1 文档切块与索引:先保证候选内容完整且可回溯
检索系统的基本单位不是“一个文件”,而是可被模型编码、可被人阅读、可被权限控制的文档块。一个 chunk 通常包含 chunk_id、document_id、原文、标题、起止偏移、来源、时间、权限标签和切块版本。只有向量而没有这些字段,检索命中后无法展示证据,也无法判断是否重复或有权访问。
切块策略要平衡语义完整性和长度预算。按段落切分通常保留自然边界,按句子切分更细,固定 Token 窗口容易控制成本,带 overlap 的滑动窗口可以减少边界截断。标题、章节名和文档路径可以作为元数据前缀加入编码文本,但展示时要区分元数据与正文,避免答案引用了导航文字。
建立索引前先做文档快照。记录清洗版本、Tokenizer、Embedding 模型、维度、归一化、chunk_size、overlap 和生成时间;为每个 chunk 计算内容哈希,重复索引时可以跳过未变化的块。文档删除、权限变化或模型升级时,索引要能增量更新或整体重建,不能依赖一个永远增长的二进制文件。
检索命中只是候选,证据链还需要原文和版本。
| 字段 | 作用 | 示例 | 缺失风险 |
|---|---|---|---|
| chunk_id | 唯一块身份 | doc-01#03 | 无法引用/去重 |
| document_id | 回到原文件 | doc-01 | 权限和更新困难 |
| offset | 原文起止位置 | 120-388 | 无法高亮 |
| text_hash | 内容指纹 | sha256... | 重复索引 |
| source_version | 来源版本 | kb-2026-08 | 结果不可复现 |
| acl | 可见范围 | team-a | 越权返回 |
from dataclasses import dataclass, asdict
import hashlib
import json
@dataclass
class Chunk:
chunk_id: str
document_id: str
text: str
start: int
end: int
version: str
def make_chunks(document_id, text, size=120, overlap=20, version="v1"):
if size <= 0 or not 0 <= overlap < size:
raise ValueError("size/overlap 不合法")
chunks = []
step = size - overlap
for start in range(0, len(text), step):
piece = text[start:start + size]
if not piece:
break
chunk_id = f"{document_id}#{len(chunks):04d}"
chunks.append(Chunk(chunk_id, document_id, piece, start, start + len(piece), version))
if start + size >= len(text):
break
return chunks
chunks = make_chunks("doc-1", "这是一段用于检索的技术文档。" * 30)
with open("chunks.json", "w", encoding="utf-8") as handle:
json.dump([asdict(chunk) for chunk in chunks], handle, ensure_ascii=False, indent=2)
print(len(chunks), chunks[0])示例用字符位置演示滑动窗口;如果实际窗口按 Token 计算,offset 应由 tokenizer 提供或建立 Token 到字符的映射。chunk 的末尾可以短于 size,但不能生成空块。每个块的 ID 要稳定,文本未变时重跑应得到同一 ID 和哈希。
切块的三个反例
只按固定字符数切块,可能把标题、表格行或代码函数截断;只按自然段切块,可能让一个超长段落超过模型上限;只保存向量不保存原文,命中后无法核验。遇到长段落,可以先按句子切分再合并到目标长度,并在 metadata 里标记是否跨段。
重复 chunk 会让 Top-k 被同一内容占满。索引前按 text_hash 去重,或检索后按 document_id 做多样性过滤;选择哪一种要根据任务。报告中说明去重发生在索引前还是排序后,否则 Recall@k 的分母和候选集合都不清楚。
选择一篇 500~1000 字的技术说明,分别用段落、固定窗口和带 overlap 的窗口切分。为每个 chunk 保存 ID、offset、长度和标题,比较哪种策略最容易回到原文。
7.7.2 Top-k 检索与重排:先召回,再判断相关性
向量检索通常分两阶段。第一阶段用便宜的点积或余弦相似度,从全部候选中召回较大的 k;第二阶段对候选做更精细的判断,例如交叉编码器、关键词匹配、字段权重、时间衰减或业务规则。粗排 k 太小会漏掉正确答案,太大则增加重排成本。先用小数据测量 Recall@k,再决定 k 的预算。
查询和文档可能需要不同的模板或编码方式。查询可以保留问题意图,文档可以加标题和来源;如果模型训练时使用了 query/document 前缀,推理时必须保持一致。查询端和文档端的维度、归一化、dtype 和模型版本必须匹配,任何一项不一致都应在索引加载时失败。
重排分数不一定与向量分数同尺度。合并时可以做标准化、学习一个线性权重或采用词典序规则,但要用验证集选择,不能凭一条案例手调。结果列表应保留粗排分数、重排分数、过滤原因和最终 rank,帮助复盘“为什么这篇文档从第 1 名掉到第 6 名”。
| 阶段 | 输入规模 | 典型方法 | 主要目标 |
|---|---|---|---|
| 候选召回 | 全库 | 余弦/点积/ANN | 高召回、低延迟 |
| 去重过滤 | Top-k 候选 | document_id/权限 | 合规与多样性 |
| 精排 | 几十条 | 交叉编码器/规则 | 更准确的相关性 |
| 阈值决策 | 精排结果 | 验证集阈值 | 允许无结果 |
| 展示 | 最终少量 | 证据片段/高亮 | 可读、可追溯 |
def rerank(candidates, keyword_scores, alpha=0.75, threshold=None):
scored = []
for item in candidates:
semantic = float(item["semantic_score"])
keyword = float(keyword_scores.get(item["chunk_id"], 0.0))
final = alpha * semantic + (1.0 - alpha) * keyword
scored.append({**item, "keyword_score": keyword, "final_score": final})
scored.sort(key=lambda item: (-item["final_score"], item["chunk_id"]))
if threshold is not None:
scored = [item for item in scored if item["final_score"] >= threshold]
return scored
candidates = [
{"chunk_id": "a", "semantic_score": 0.81},
{"chunk_id": "b", "semantic_score": 0.79},
]
print(rerank(candidates, {"b": 0.9}, alpha=0.7))重排示例用简单线性组合帮助理解职责,真实系统可以替换为更强的模型。先做粗排再重排能节省资源,但必须在同一组人工标注上比较两阶段与单阶段的 Recall、MRR、延迟和无结果率。排序函数还要处理分数缺失、重复 chunk 和候选为空。
过滤后少于 k 条是正常的,不能用不合规结果补足。
延迟账本与质量账本要同时记录
一次检索至少记录查询 Token 数、Embedding 时间、索引搜索时间、重排时间、过滤数量、最终返回数和总 wall-clock。质量侧记录 Recall@k、MRR、无结果率和人工相关率。只优化延迟可能让召回下降,只优化指标可能让成本失控;两本账放在同一份 run 记录里,才能做真实取舍。
候选索引可以分片或分块计算。分块搜索时每块先取局部 Top-k,再合并成全局 Top-k;不能简单拼接局部结果。合并时保留稳定 tie-breaker,使用相同 dtype 和归一化,否则边界分数可能出现不一致。
为十篇文档和三条查询设计召回 k、去重规则、重排分数和阈值。记录每一阶段的候选数、耗时、命中情况和过滤原因。
7.7.3 检索结果可解释:把“相似”翻译成人能核验的证据
一个检索系统的用户体验不只由排序决定,还由结果能否被核验决定。返回结果应包括文档标题、来源、chunk 原文、匹配分数、rank、更新时间和必要的权限提示;如果支持高亮,保存 Token 或字符 offset。不要只返回一串文档 ID,也不要把模型生成的总结当成原始证据。
解释相似度时要区分三种说法:模型空间认为接近、规则层认为包含关键词、人工标注认为相关。它们可能一致,也可能冲突。结果卡片可以展示向量分数与关键词命中,但结论仍应以任务标注和人工复核为准。对没有相关结果的查询,说明“候选不足”“被权限过滤”还是“低于阈值”,比静默返回空白更有帮助。
权限过滤必须在展示前完成,最好在候选查询阶段就缩小可见集合。文档更新或撤销权限后,缓存的检索结果要失效;否则用户可能看到已不应访问的片段。记录查询主体、索引版本和过滤规则,才能审计一次结果是否合规。学习项目可以用简单的 acl 标签模拟这一流程。
| 字段 | 展示/审计用途 | 来源 | 注意 |
|---|---|---|---|
| title/source | 用户定位 | 文档元数据 | 不能来自模型幻觉 |
| text/offset | 阅读与高亮 | 原文快照 | 切块版本一致 |
| semantic_score | 表示相似度 | 向量模型 | 说明归一化 |
| keyword_hits | 规则证据 | 查询/文档 | 不是语义证明 |
| filter_reason | 解释缺结果 | 权限/阈值 | 避免静默丢弃 |
| index_version | 复现与回滚 | manifest | 与模型绑定 |
def visible_results(results, user_groups, threshold=0.55):
output = []
for item in results:
if item.get("acl") and not set(item["acl"]) & set(user_groups):
item = {**item, "filter_reason": "acl"}
continue
if item["score"] < threshold:
continue
output.append({
"rank": len(output) + 1,
"chunk_id": item["chunk_id"],
"title": item["title"],
"text": item["text"],
"score": round(float(item["score"]), 4),
"source": item["source"],
"index_version": item["index_version"],
})
return output
print(visible_results([{"chunk_id": "a", "title": "向量", "text": "...", "source": "kb", "score": 0.8, "index_version": "v1", "acl": ["ml"]}], ["ml"]))示例把权限和阈值作为展示前的过滤层。正式系统还要返回被过滤数量、无结果原因和审计 ID,但不要把敏感文档内容放进错误消息。结果的 rank 应在过滤后重新计算,避免显示“第 4 名”却只有三条可见结果。
一条结果的证据卡
可以把每条结果整理成“查询—候选—模型—规则—权限—原文”六格证据卡:查询 ID 和原文,candidate/chunk ID,Embedding 模型和分数,关键词或重排信号,用户可见范围,最终展示片段。出现错误时沿六格回放,能够快速判断是向量不相似、规则过滤、权限变化还是展示映射错。
解释文本时避免使用“模型认为这段一定正确”。相似度只是排序信号,不能替代事实核验。对知识库问答,后续生成模型也应引用检索到的原文并标记不确定;本章先把检索证据链做好。
选择一条成功、一条相似但错误、一条因权限被过滤的检索结果,分别填写查询、chunk、分数、关键词、权限、原文和索引版本。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 切块建索引 | 能保存 chunk ID、offset、版本和权限 | 批量召回 |
| 两阶段排序 | 能解释召回、重排、阈值和去重 | 评估指标 |
| 处理合规 | 能过滤不可见文档并说明原因 | 发布检索 |
| 展示证据 | 结果能回到原文而非只有分数 | 端到端项目 |
08 · EVALUATION & ERROR ANALYSIS
评估与错误分析:让“效果好”有可以复核的证据
文本分类和语义检索都不能只看一个漂亮的准确率。本节把指标定义、数据泄漏、切片分析、回归测试和报告格式连起来,让一次实验可以被别人复现,也能解释下一步为什么要改数据、分词器或模型。
把每个数字绑定到一个可复算的问题
评估前先写“如果系统真的有用,用户会看到什么变化”。分类可能是少数类漏报下降,检索可能是第一条证据更常包含答案;这些目标再映射到 macro-F1、Recall@k、MRR 或人工采纳率。报告同时保留支持数和失败样例,防止一个总体均值掩盖小群体的严重问题。
标注协议要规定边界:同义表达算相关吗?只包含关键词但没有回答算相关吗?多段拼起来才能回答的问题,单个 chunk 给几级相关性?两位标注者意见不一致时如何仲裁?协议变化会改变 gold,必须记录版本并重新解释旧指标。宁可承认一部分样例“待复核”,也不要用模糊标签制造虚假的确定性。
| 字段 | 示例 | 作用 |
|---|---|---|
| metric_version | ranking-v2 | 固定计算定义 |
| support | queries=40 | 判断样本量 |
| parameters | k=5、threshold=0.72 | 解释决策点 |
| slice | short_text、new_source | 定位分布差异 |
| tolerance | absolute=0.01 | 定义回归边界 |
| evidence | gold-v1、error_ids | 回到原始案例 |
回归测试应分成快速 smoke、完整离线评估和人工复核三层。Smoke 只跑几十个固定样例,检查接口、形状、ID、空值和权限;完整评估计算全部指标和切片;人工复核查看最重要的升降与新出现的错误。三层速度不同,但都引用同一份版本清单,避免快速测试与正式评估使用不同规则。
对于随机模型或小数据,保存多个 seed 的结果;对于检索,保存每条查询的排名而不只是平均 Recall。遇到指标变化时,可用每条样例的配对差异查看是少数案例推动,还是普遍改善。统计显著性不是唯一标准,业务风险、延迟和证据质量同样重要。
def compare(current, baseline, gates):
failures = []
for name, rule in gates.items():
now, old = current[name], baseline[name]
delta = now - old
if rule["direction"] == "up" and delta < -rule["tolerance"]:
failures.append((name, delta, "下降超过容差"))
if rule["direction"] == "down" and delta > rule["tolerance"]:
failures.append((name, delta, "上升超过容差"))
return failures
gates = {
"macro_f1": {"direction": "up", "tolerance": 0.01},
"empty_rate": {"direction": "down", "tolerance": 0.02},
}
print(compare({"macro_f1": 0.79, "empty_rate": 0.08},
{"macro_f1": 0.80, "empty_rate": 0.07}, gates))门禁规则只负责发现变化,不负责替你决定是否发布。报告里把“硬失败”和“需要人工确认”分开:核心少数类召回大幅下降可能硬失败;某个小切片有轻微波动则进入人工复核。每次豁免也要写原因和到期时间,避免告警永久被忽略。
取同一份 gold 上的两个版本结果,列出每条查询的排名变化,找出三条改善和三条退化,并判断总体指标是否掩盖了哪一种错误。
7.8.1 分类与检索指标:先写清分子、分母和使用场景
指标不是装饰在报告末尾的数字,而是任务合同的可计算版本。分类任务先确定样本是否只有一个标签;如果一条文本可能同时属于多个类别,就不能直接套用单标签准确率。二分类中的准确率是预测正确的样本数除以总样本数,精确率关注“预测为正的里面有多少真的为正”,召回率关注“所有正例中找回了多少”,F1 是精确率和召回率的调和平均。每个分母都代表一种错误代价,不能只因为某个数字更大就宣布模型更好。
类别不均衡时,准确率尤其容易误导。假设 95% 的工单都是“普通”,模型永远预测普通也有 95% 准确率,却完全没有识别紧急工单的能力。此时应查看每类的 precision、recall、F1,以及 macro、weighted 和 micro 的聚合方式。macro 先对每个类别等权平均,能暴露少数类问题;weighted 按支持数加权,比较接近总体样本体验;micro 先汇总全部 TP、FP、FN,再计算一个总体值。报告中写出聚合方式,读者才知道数字代表什么。
检索指标同样依赖明确的相关性标注。Recall@k 问“至少一个相关结果是否出现在前 k 条”,Precision@k 问“前 k 条中有多少相关”,MRR 只看第一个相关结果的位置,nDCG 则允许相关性有等级。对于知识库搜索,第一条结果是否包含能直接回答问题的证据,往往比把十条结果平均得分更重要。若一个查询没有标注相关文档,不能把它粗暴计为失败或成功,应单独统计缺标注数量。
指标还要带上切分方式和置信范围。同一份测试集上换一个随机种子,F1 可能有小幅波动;在几十个样本上,0.02 的提升未必有实际意义。学习阶段可以先报告样本数、正负例数和每类支持数,再用 bootstrap 或多次 seed 给出均值与区间。线上系统还应增加延迟、空结果率、重复结果率和人工采纳率,因为用户不会只看到离线的 Recall@10。
一条没有数据版本、样本量和切片定义的指标,不能独立证明模型进步。
| 指标 | 它回答什么 | 适合场景 | 容易误读的地方 |
|---|---|---|---|
| Accuracy | 全部样本中预测对多少 | 类别均衡、错误代价接近 | 被多数类“冲高” |
| Precision | 判为正的有多少是真的 | 误报成本高 | 忽略漏掉的正例 |
| Recall | 真实正例找回多少 | 漏报成本高 | 可能牺牲大量精确率 |
| Macro-F1 | 每类 F1 的等权平均 | 关注少数类公平性 | 小类样本太少时方差大 |
| Recall@k | 前 k 条是否覆盖相关项 | 候选召回阶段 | 不关心相关项排第几 |
| MRR | 第一个相关项有多靠前 | 单一最佳答案搜索 | 忽略第二、第三个相关项 |
| nDCG@k | 按等级衡量排序质量 | 相关性有多级标注 | 需要稳定的 gain 定义 |
from collections import Counter
def binary_counts(y_true, y_pred, positive=1):
tp = sum(t == positive and p == positive for t, p in zip(y_true, y_pred))
fp = sum(t != positive and p == positive for t, p in zip(y_true, y_pred))
fn = sum(t == positive and p != positive for t, p in zip(y_true, y_pred))
return tp, fp, fn
def precision_recall_f1(y_true, y_pred, positive=1):
tp, fp, fn = binary_counts(y_true, y_pred, positive)
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
return {"precision": precision, "recall": recall, "f1": f1, "support": tp + fn}
def recall_at_k(relevant_ids, ranked_ids, k):
relevant = set(relevant_ids)
return float(bool(relevant.intersection(ranked_ids[:k]))) if relevant else None
def mrr(relevant_ids, ranked_ids):
relevant = set(relevant_ids)
for rank, item_id in enumerate(ranked_ids, start=1):
if item_id in relevant:
return 1.0 / rank
return 0.0
y_true = [1, 1, 0, 0, 0]
y_pred = [1, 0, 0, 1, 0]
print(precision_recall_f1(y_true, y_pred))
print(recall_at_k(["doc-2"], ["doc-5", "doc-2"], k=2))代码故意把指标写成小函数,并明确空分母的处理。正式项目还要校验 y_true 与 y_pred 长度一致、标签集合一致、检索列表没有重复 ID。指标函数的输入输出应写进测试,不能依赖某次 notebook 中恰好存在的全局变量。
读懂指标的四个追问
第一,样本单位是什么?一行是句子、文档、查询,还是一个用户会话?第二,正例是谁标的,标注指南怎样处理争议?第三,阈值或 k 是事先固定还是在测试集上挑出来的?第四,指标下降时,用户实际多了哪一种错误?这四个追问能把“F1 从 0.81 变成 0.83”的兴奋,变成可以行动的结论。
不要把训练集、验证集和测试集的结果放在同一张没有标题的表里。至少标出 split、样本数、模型版本、预处理版本和运行时间。对于检索,另外记录候选池大小、去重规则、过滤规则和是否经过重排。只有上下文相同的数字才适合横向比较。
选择“分类 macro-F1”或“检索 Recall@5”,写出样本单位、标签/相关性定义、分母、空值策略、统计 split 和一次失败时要看的切片。
7.8.2 数据泄漏与偏差:先证明测试集真的代表未来
数据泄漏是评估中最危险的“隐形加分”。最直观的泄漏是同一段文本同时出现在训练和测试;更隐蔽的情况包括同一用户的模板化工单跨 split、同一篇文章的相邻段落被拆开、先用全量语料拟合词表或标准化统计量、用测试查询参与负例采样,以及用未来时间的信息构造当前特征。模型可能确实学会了重复模式,但这不等于能处理新数据。
切分策略要符合部署时的数据到达方式。随机切分适合样本相互独立的静态数据;按用户或文档分组切分,适合防止同源文本泄漏;按时间切分,适合模拟“用过去预测未来”;跨域切分则用来测试换业务、换语言或换写作风格后的稳定性。没有一种切分永远正确,关键是把假设写出来,并用另一种更严格的切分做压力测试。
偏差不只是标签比例不同。长度、语言、主题、来源、设备、时间、版权状态和人工编辑方式都可能让模型偏向某些切片。一个总体 F1 可能掩盖长文本很好、短文本很差,中文很好、夹英文很差,旧文档很好、新文档很差。切片不是为了找一个“最好看的群体”,而是为了找出用户会实际遇到的盲区,并决定是否补数据或改变产品边界。
近似重复需要比字符串相等更谨慎的检测。可以先做 NFC、空白规范化和小写折叠后的哈希,再对疑似重复使用 n-gram、MinHash 或向量相似度检查。检测算法本身也要保存版本和阈值;阈值太低会把正常相似的模板误判为泄漏,阈值太高又会漏掉轻微改写。抽样人工复核是必要的,自动审计只负责缩小范围。
| 检查层 | 示例问题 | 证据 | 修复动作 |
|---|---|---|---|
| 记录级 | ID、哈希是否跨 split | 交集清单 | 重新分组切分 |
| 来源级 | 同文档/用户是否拆散 | group overlap | 按来源锁定 split |
| 时间级 | 训练是否看到未来 | 时间分布图 | 时间窗重建 |
| 处理级 | 词表、均值是否用全量拟合 | fit 日志 | 只在 train fit |
| 切片级 | 长度、语言、主题是否失衡 | slice report | 补样本/改边界 |
| 标注级 | 测试答案是否泄露进提示 | 人工抽查 | 隔离 gold 信息 |
import hashlib
from collections import defaultdict
def normalized_key(text):
return " ".join(text.casefold().split())
def audit_splits(records):
by_key = defaultdict(list)
by_group = defaultdict(set)
for row in records:
key = hashlib.sha256(normalized_key(row["text"]).encode("utf-8")).hexdigest()
by_key[key].append((row["id"], row["split"]))
by_group[row["group_id"]].add(row["split"])
duplicate_leaks = [
items for items in by_key.values()
if len({split for _, split in items}) > 1
]
group_leaks = [
{"group_id": group_id, "splits": sorted(splits)}
for group_id, splits in by_group.items() if len(splits) > 1
]
return {"duplicate_leaks": duplicate_leaks, "group_leaks": group_leaks}
rows = [
{"id": "a", "text": "重试接口", "split": "train", "group_id": "doc-1"},
{"id": "b", "text": "重试 接口", "split": "test", "group_id": "doc-2"},
]
print(audit_splits(rows))示例只做精确规范化后的重复审计,不能替代人工或近似重复检测。注意 group_id 可以是用户、文档、项目或会话,应该由任务风险决定。审计结果要进入实验产物;如果发现泄漏后只在聊天里口头提醒,下一次运行很容易再次引入。
高分需要解释;异常高分首先是审计信号,而不是庆祝理由。
偏差报告不要变成泛泛的道德口号
好的偏差记录会写出“哪一组、多少样本、哪一个指标、与总体差多少、可能原因、下一步验证”。例如:“短文本(Token≤8)占测试集 18%,macro-F1 比总体低 0.14;错误多为上下文不足,下一轮增加短标题样本并测试字符级基线。”这样的句子既承认风险,也让修复可以被验证。
如果某一切片样本太少,不要给出过度精确的排名。可以报告支持数、置信区间和“暂不下结论”,并把它列入数据收集计划。谨慎不是降低标准,而是避免把噪声包装成事实。
假设你的文本分类器将用于持续到来的客服工单,设计随机、按用户、按时间三种切分,并预测每种切分会暴露哪类问题。
7.8.3 回归测试与报告:让每次改动都有可比较的记录
当你修改清洗规则、词表、Embedding 模型或检索阈值时,最怕“感觉变好了”却无法指出变化来自哪里。回归测试把一小组固定样例和指标保存下来,每次运行都自动比较。它不要求样例覆盖所有世界,而是要求最容易坏、最重要和最容易被忽略的行为有明确断言:中文与英文混排、空文本、超长文本、同义但不同写法、无结果查询、权限过滤、重复结果和模型加载失败。
金标准集合(golden set)要有稳定 ID、输入、期望标签或相关文档、难度、来源和备注。检索样例最好同时保存“必须出现的证据”和“不能出现的干扰项”,这样可以检查召回和排序。对开放文本,不要把答案写成一个唯一字符串;可以保存多个可接受标签、相关性等级或人工评分。任何修改 gold 的操作都要走审阅,否则团队可以通过改答案让指标“回归成功”。
报告至少分成四层:运行元数据、数据统计、核心指标、错误样例。运行元数据包括代码版本、依赖版本、Python/PyTorch 版本、设备、seed、词表和索引版本;数据统计包括 split、长度分布、类别支持数和过滤数量;核心指标按任务合同列出;错误样例带原文、预测、期望、分数、规则和下一步。把这些内容写成 JSON 与可读 Markdown 两份,既方便机器比较,也方便人阅读。
阈值回归与排序回归要分开。分类模型可能在阈值 0.5 下 F1 下降,但在业务允许的阈值区间仍然可接受;检索模型可能 Recall@10 不变,却把第一条证据排得更靠前。定义容差时注明“绝对差”还是“相对差”,并为关键指标设置硬门槛,为探索性指标设置告警而不是一律失败。
| 区块 | 字段 | 为什么需要 | 缺失后果 |
|---|---|---|---|
| 运行 | run_id、时间、commit、seed、设备 | 复现和定位 | 无法确认是否同一实验 |
| 输入 | data_version、preprocess_version、split | 固定数据口径 | 数字不可比较 |
| 表示 | tokenizer、vocab、embedding/index version | 解释输入变化 | 误把表示变化归因模型 |
| 指标 | 名称、值、支持数、阈值/k | 避免分母歧义 | 不能复算 |
| 错误 | 样例 ID、错误类型、下一步 | 连接分析与行动 | 只剩一个总分 |
import json
from datetime import datetime, timezone
from pathlib import Path
def build_report(meta, metrics, errors, slices):
return {
"run_id": meta["run_id"],
"created_at": datetime.now(timezone.utc).isoformat(),
"versions": {
"code": meta["code_version"],
"data": meta["data_version"],
"preprocess": meta["preprocess_version"],
"tokenizer": meta["tokenizer_version"],
"index": meta.get("index_version"),
},
"metrics": metrics,
"slices": slices,
"errors": errors,
}
report = build_report(
meta={"run_id": "exp-007", "code_version": "git:abc123",
"data_version": "corpus-v2", "preprocess_version": "clean-3",
"tokenizer_version": "tok-1", "index_version": "idx-4"},
metrics={"macro_f1": 0.78, "recall_at_5": 0.92},
slices={"short_text": {"support": 18, "macro_f1": 0.64}},
errors=[{"id": "q-03", "type": "near_duplicate", "next": "add hard negative"}],
)
Path("report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print("saved report.json")报告生成器不应该读取屏幕上的复制粘贴数字。它应直接接收评估函数返回的结构化对象,并把环境和版本从配置或运行器传入。写完后用 JSON 再读一次,检查中文、浮点数和可选字段都能正确恢复。报告本身也可以成为下一章工程化流水线的输入。
回归失败时的处理顺序
先确认输入和版本是否真的相同,再确认失败是硬门槛还是统计波动;然后定位到具体切片和样例,最后决定是修代码、修数据、改阈值还是更新任务边界。不要一看到总分下降就立刻调大模型。很多回归来自空白处理、排序 tie-break、权限过滤或样例标签变化。
一份成熟的报告会保留“未解决问题”栏目。比如某类长文档仍会被固定窗口截断,当前只通过提高重叠缓解,尚未验证新的分块策略。把未知写出来,后续的人就不会把临时补丁误当成最终方案。
为本章的文本分类或检索项目列出 8 个固定样例,至少覆盖空文本、超长文本、近似重复、无结果、边界分数和一个正常成功案例,并为每个样例写断言。
| 能力 | 最低证据 | 下一步 |
|---|---|---|
| 选择指标 | 能写清分子、分母、k/阈值和空值 | 建立 gold set |
| 识别泄漏 | 能按 ID、来源、时间和处理范围审计 | 压力切分 |
| 分析错误 | 能按切片给出支持数和下一步 | 回归样例 |
| 输出报告 | JSON 可复读,Markdown 可阅读 | 端到端交付 |
09 · CAPSTONE PROJECT
综合项目:做一个可追溯的中文技术问答检索器
最后一节把本章的概念串成一个小而完整的系统:读取几篇公开技术文档,清洗并切块,建立词表和向量表示,召回候选、重排结果、展示证据,再用固定样例评估。项目不追求“像产品一样复杂”,而追求每一步都能解释、运行和复盘。
把综合项目拆成三个可独立验收的里程碑
里程碑一是“文本可追溯”:输入公开文件,产出 documents 和 chunks,任意 chunk 都能通过文档 ID 与 offset 回到原文。里程碑二是“结果可解释”:输入查询,返回稳定排序、来源、分数、过滤理由和无结果原因。里程碑三是“结论可复核”:固定 gold,生成指标、切片、错误和版本报告。每个里程碑都先用标准库基线完成,再接入更复杂的模型。
验收时做一次故障演练:修改清洗版本、删除一个索引文件、把一个用户 ACL 改掉、输入空查询、加入重复文档。系统应明确失败或给出降级,不应继续输出看似正常但无法追溯的结果。故障演练比只测试成功路径更能证明项目真的掌握了数据契约。
| 里程碑 | 输入 | 输出 | 验收问题 |
|---|---|---|---|
| 文本可追溯 | 公开文档 | documents/chunks/manifest | 能否回到字符 offset |
| 结果可解释 | 查询与 ACL | ranked evidence cards | 能否说明为何出现/过滤 |
| 结论可复核 | gold 与配置 | report、errors、cost | 改动后能否比较 |
| 故障可控 | 空值、损坏、版本错配 | 明确错误或安全降级 | 是否避免静默错误 |
from pathlib import Path
import hashlib
import json
def file_hash(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
root = Path("artifacts")
manifest = {
"project_version": "chapter7-v1",
"files": {
name: file_hash(root / name)
for name in ["documents.jsonl", "chunks.jsonl",
"vocab.json", "index.json", "gold.json"]
if (root / name).exists()
},
}
(root / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("manifest entries:", len(manifest["files"]))项目档案不只是给老师打分,也是在为后续 Transformer 实验保留稳定起点。下一章替换编码器后,仍可以使用同一批 chunks、gold 和 smoke test;若结果变化,才能把原因聚焦到上下文表示、注意力或微调策略。
从四种故障中选择三种:删除索引、版本错配、权限收紧、空查询。记录系统预期输出、实际输出、发现位置和修复后的回归样例。
把作品写成一份可交接的项目档案
项目目录可以采用 data/raw、data/processed、artifacts、src、tests 和 reports 的分层。原始文件只读,处理结果带版本,模型和索引由 manifest 指向,报告按 run_id 保存。目录清晰不是形式主义:当你换一台电脑、重建词表或回看一个错误查询时,能迅速找到该找的文件。
README 开头写一个五分钟成功路径,再写完整命令和限制。成功路径应能从少量公开样例生成一个结果;完整路径再加入批处理、评估和消融。每个命令注明输入、输出和预计耗时,遇到失败给出常见原因。不要把“在我的 notebook 里运行过”当作安装说明,隐藏状态会让别人无法复现。
交付验收可以按功能、质量、可解释性和安全四个维度评分。功能看能否读取、切块、编码、检索和返回结果;质量看 gold 指标与回归;可解释性看结果能否回到来源和 offset;安全看 ACL、日志和删除。代码行数只是一种成本指标,不是学习成果。职责清楚、数据契约稳定、失败边界诚实,比堆更多模型层更重要。
| 目录/文件 | 内容 | 提交前检查 |
|---|---|---|
| README.md | 目标、环境、命令、限制 | 新环境能照做 |
| config/ | 长度、阈值、模型、路径 | 无本机绝对路径 |
| src/ | 读取、处理、表示、检索、评估 | 函数职责单一 |
| tests/ | 往返、形状、边界、权限 | 失败信息具体 |
| artifacts/ | 词表、索引、manifest、gold | 哈希和版本齐全 |
| reports/ | 指标、切片、错误和成本 | JSON/Markdown 一致 |
def smoke_search(pipeline):
result = pipeline.search("如何设置编码", user_acl={"public"}, top_k=3)
assert result["query_id"]
assert len(result["items"]) <= 3
for item in result["items"]:
assert item["chunk_id"]
assert item["source"]
assert item["text"]
assert item["index_version"]
assert set(item["acl"]).intersection({"public"})
return {"status": "ok", "items": len(result["items"])}
print(smoke_search(pipeline))Smoke test 不需要知道模型内部每个浮点数,但要检查用户真正依赖的合同:结果数量、字段完整、权限可见、版本存在、空结果有原因。再增加一个无权限查询和一个没有相关文档的查询,确认系统不会通过异常或日志泄露正文。测试数据可以很小,边界要有代表性。
后续进入 Transformer 学习时,可以保持项目接口不变,只替换表示模块:把平均 Embedding 换成上下文编码器,把静态相似度换成训练好的句向量,把简单重排换成交互模型。每次替换都重新跑同一份 gold 和回归样例,才能知道提升来自上下文表示还是其他改动。项目因此成为贯穿后续章节的实验基座。
用 200~300 字说明项目目标、数据范围、一次成功命令、输出字段、已知限制和下一步实验。让没有看过代码的同学也能判断项目是否符合任务边界。
7.9.1 文本搜索端到端项目:从原文到第一条可核验结果
项目场景是“个人技术笔记搜索”。输入是一组带标题和来源的 Markdown 或纯文本文件,输出是与查询最相关的若干段落。为了让初学者能独立完成,第一版只使用 Python 标准库和一个简单的 TF-IDF/词频基线;第二版再替换为平均 Embedding 或预训练句向量。先有可解释的基线,才能判断后面的向量模型到底带来了什么。
先定义最小数据结构:文档有 document_id、title、source、text、updated_at 和 acl;切块有 chunk_id、document_id、chunk_index、字符起止位置、处理版本和正文。查询也要记录 ID、原文、用户可见权限和期望相关 chunk。结构先稳定,后续更换算法时才不会重写所有展示和评估代码。
切块策略可以从段落开始:先按空行分段,过长段落再按句子或固定窗口拆分,并保留少量重叠。每块应该尽量表达一个完整主题,而不是追求恰好相同的字符数。标题可以拼到块前作为上下文,但要同时保留独立的标题字段,方便结果展示。对代码块、表格和列表要避免在中间截断;如果暂时做不到,至少在 metadata 中标记边界类型。
第一版索引可以用一个字典保存每个 chunk 的稀疏词频,再用查询词与 chunk 词频的点积排序。这个基线不懂同义词,却非常适合检查 ID、去重、权限过滤、Top-k 和证据展示是否正确。项目通过后,再把 encode 函数替换为向量模型,并保留相同的索引和评估接口,才能做公平对照。
不要只保存最终页面;中间产物是定位问题和复现实验的路线图。
| 文件 | 必须包含 | 由谁生成 | 检查方式 |
|---|---|---|---|
| documents.jsonl | 文档 ID、标题、来源、原文哈希 | 读取器 | ID 唯一、编码可读 |
| chunks.jsonl | chunk ID、offset、正文、处理版本 | 切块器 | 可回到原文 |
| vocab.json | token→ID、特殊 ID、版本 | 词表构建器 | encode/decode 往返 |
| index.json | 向量/稀疏表示、chunk ID、模型版本 | 索引器 | 维度和数量一致 |
| gold.json | 查询、相关 ID、难度、备注 | 人工标注 | 抽样复核 |
| report.json | 指标、切片、错误、运行元数据 | 评估器 | JSON 可再次读取 |
from collections import Counter
from pathlib import Path
import hashlib
import json
import re
def stable_id(prefix, value):
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
return f"{prefix}-{digest}"
def chunk_paragraphs(path):
raw = path.read_text(encoding="utf-8")
paragraphs = [part.strip() for part in re.split(r"\n\s*\n", raw) if part.strip()]
rows = []
offset = 0
for index, text in enumerate(paragraphs):
start = raw.find(text, offset)
end = start + len(text)
rows.append({
"chunk_id": stable_id("chunk", f"{path}:{index}:{text}"),
"document_id": stable_id("doc", str(path)),
"chunk_index": index,
"start": start,
"end": end,
"text": text,
"source": str(path),
"text_hash": hashlib.sha256(text.encode("utf-8")).hexdigest(),
})
offset = end
return rows
def sparse_score(query, text):
q, d = Counter(query.casefold().split()), Counter(text.casefold().split())
return sum(q[token] * d[token] for token in q)
paths = sorted(Path("notes").glob("*.md"))
chunks = [chunk for path in paths for chunk in chunk_paragraphs(path)]
query = "如何处理文本编码"
ranked = sorted(chunks, key=lambda row: sparse_score(query, row["text"]), reverse=True)
print(json.dumps(ranked[:3], ensure_ascii=False, indent=2))这段代码是教学基线,不是完整的中文检索器:中文没有空格时,简单 split() 几乎不会产生有用词项。你可以先把查询和正文按字符或前面学过的 Token 切分,再比较字符级、词级和子词级的差异。重要的是保留同一批 gold 查询,避免因为换了切分方式而失去可比性。
项目启动前的四张纸
第一张是任务卡:用户输入什么,结果要显示什么,什么叫“相关”,无结果时怎么办。第二张是数据字典:每个字段、来源、许可、缺失策略。第三张是版本清单:代码、清洗、分词器、词表、Embedding、索引。第四张是错误预算:允许多少空结果、重复结果、权限误展示和延迟。写完这四张纸,项目范围会比“做一个搜索器”清楚得多。
学习项目可以只用本地公开资料,不要直接把私人聊天、公司内部文档或含个人信息的文件放进练习目录。展示结果时保留出处和许可说明;自制图示可以解释架构,但不要复制受版权保护的整页截图。
准备至少 6 个短文档和 10 条查询,为每个查询标注 1~3 个相关 chunk。完成读取、切块、ID、基线排序和证据卡输出,并写一条“无结果”查询。
7.9.2 消融、缓存与版本:知道哪一个改动带来了收益
端到端跑通后,不要一次替换五个组件。消融实验只改变一个因素,其余保持固定:字符切分与子词切分、无重叠与有重叠的分块、TF-IDF 与平均 Embedding、不同 pooling、不同 Top-k、不同重排权重。每个实验记录配置和结果,哪怕结果没有提升也要保留。失败的实验能告诉你当前数据和任务还不支持某个复杂方案。
缓存是为了节省重复计算,不是为了隐藏状态。一个向量缓存键至少包含规范化文本哈希、Embedding 模型版本、维度、设备/精度策略和预处理版本;索引缓存还要加入 chunk manifest 哈希和权限策略版本。只要其中一项改变,就应生成新键或明确迁移。缓存命中率高但结果版本过期,是比“没有缓存”更危险的错误。
版本清单要能回答三个问题:这个结果是用哪份文本算的?文本经过了什么处理?模型和索引是否可以重新加载?对于小项目,可以用一个 JSON manifest;对于更大项目,再引入数据版本工具和对象存储。不要一开始就把精力放在复杂平台上,先确保每一个文件都有稳定的父子关系和哈希。
比较实验时同时观察质量和成本。一个模型 Recall@5 提升 0.03,却让索引构建时间增加 20 倍、内存增加 10 倍,是否值得要看产品目标。把延迟、内存、索引大小、每次查询的向量计算次数和空结果率也放进报告。工程判断不是“最高分获胜”,而是找到满足约束的方案。
| 维度 | 可变项 | 必须固定 | 观察结果 |
|---|---|---|---|
| 文本处理 | NFC、大小写、空白 | gold、模型、阈值 | 召回/错误类型 |
| 切分 | 段落、窗口、重叠 | 文档版本、权限 | 证据完整性、长度 |
| 分词 | 字符、词、子词 | Embedding 维度、seed | Token 数、OOV、质量 |
| 表示 | 平均、max、首 Token | chunk、查询、Top-k | Recall@k、延迟 |
| 排序 | 相似度、关键词混合权重 | 候选池、过滤规则 | MRR、首条证据 |
| 缓存 | 开启、失效策略 | 输入和版本 | 命中率、陈旧率 |
from itertools import product
import json
base = {
"data_version": "notes-v1",
"gold_version": "gold-v1",
"seed": 7,
"top_k": 5,
}
grid = {
"splitter": ["char", "subword"],
"pooling": ["mean", "max"],
"rerank_weight": [0.0, 0.2],
}
runs = []
for values in product(*grid.values()):
config = {**base, **dict(zip(grid, values))}
config["run_id"] = "ablation-" + "-".join(str(value) for value in values)
runs.append(config)
with open("ablation_plan.json", "w", encoding="utf-8") as handle:
json.dump(runs, handle, ensure_ascii=False, indent=2)
print("planned runs:", len(runs))配置生成器的价值是避免手工改错参数。运行器读取每一行配置,输出同名 run_id 的报告,最后再由一个汇总脚本合并。若中途失败,重跑失败的 run 即可;不要因为一个配置报错就覆盖已经完成的报告。
只用文件名做缓存键,会把同名不同内容的结果混在一起。
三种常见的“假提升”
第一,测试查询被加入了词表或索引,导致 OOV 减少但未来并没有这份信息。第二,调参多次后只报告最好的一次,忽略了选择过程本身使用了测试集。第三,缓存没有包含预处理版本,实际上读取的是旧向量。排查时把原始 manifest、运行日志和缓存命中信息一起看,通常比重新训练更快找到原因。
消融表最好同时有“配置、核心指标、支持数、成本、观察、决定”。“不显著”也是结论:它可能说明数据太小、组件不适合任务,或需要更合理的标注,而不是说明实验白做了。
固定数据和 gold,只分别改变切分单位、pooling、Top-k、重排权重四项中的任意四组,填写质量、延迟和内存;写出一项没有提升的结论。
7.9.3 交付与下一步:把实验变成可以继续学习的作品
一个合格的学习项目不要求上线,但要像可交付物一样容易运行。README 写清环境、安装、数据放置、命令顺序、输出文件、已知限制和许可证;manifest 列出输入与产物的哈希;配置文件不要依赖本机绝对路径;代码把读取、处理、表示、检索、评估、展示拆成职责清楚的函数。别人按照 README 操作,应该能得到结构相同的报告,而不是只能看你的屏幕录制。
输出界面至少展示标题、来源、正文片段、chunk ID、相似度、关键词信号、索引版本和过滤状态。用户如果点击“为什么有这条结果”,应能看到证据,而不是一句“AI 推荐”。如果没有足够相关内容,就明确说无结果并给出改写查询或扩大范围的建议。解释不等于保证正确,页面要提示相似度和答案仍需核验。
隐私和权限在学习项目里也不能完全跳过。把公开资料和私有资料分目录,检索时先过滤用户无权看到的 chunk,再做展示;错误日志不要打印整段敏感原文;删除文档时同时删除 chunk、向量和缓存。权限字段是结果的一部分,不是 UI 做完以后才补的开关。
本章结束后,下一步是 Transformer:注意力机制会让模型不只把 Token 向量做简单平均,而是根据上下文动态组合表示。进入下一章前,你应该能回答:一个 Token 如何变成向量?mask 为什么存在?相似度为什么不能代表事实正确?检索结果如何回到原文?评估失败时先查哪里?如果这些问题还不稳,回到对应小节重新跑项目中的最小实验。
| 类别 | 检查项 | 通过标准 | 证据文件 |
|---|---|---|---|
| 运行 | 全新环境按 README 执行 | 命令顺序明确、无隐藏路径 | README、requirements |
| 数据 | 原文、许可、哈希和版本 | 能说明来源与处理 | documents.jsonl、manifest |
| 表示 | 词表/模型/索引可加载 | 维度、ID、版本一致 | vocab、index manifest |
| 质量 | gold、指标、切片、错误 | 报告可读且可复算 | gold、report |
| 安全 | 权限、日志、删除策略 | 无越权正文和敏感输出 | policy、audit log |
| 回归 | 固定样例与 smoke test | 改动后能快速发现坏行为 | tests、baseline |
import json
from pathlib import Path
REQUIRED = {
"documents.jsonl",
"chunks.jsonl",
"vocab.json",
"index.json",
"gold.json",
"report.json",
}
def check_json(path):
with path.open(encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, (dict, list)):
raise ValueError(f"{path} 顶层必须是对象或数组")
root = Path("artifacts")
missing = sorted(name for name in REQUIRED if not (root / name).exists())
if missing:
raise SystemExit("missing: " + ", ".join(missing))
for name in ("vocab.json", "index.json", "gold.json", "report.json"):
check_json(root / name)
print("release checklist passed")发布检查脚本仍然很小,却体现一个重要习惯:把“我记得有这些文件”变成机器可以重复检查的条件。你还可以加入代码 AST 检查、索引向量维度检查、gold ID 存在性检查和权限字段非空检查。检查失败时给出具体文件和原因,修复成本会低很多。
从项目作品回看知识地图
如果文本乱码,回到 7.1.1 看 bytes、str 和编码;如果长度忽长忽短,回到 7.1.2 和 7.2.1 看规范化与 Token;如果 ID 对不上,回到 7.3.1~7.3.3 看特殊符号、padding 和截断;如果句子向量不稳定,回到 7.4.1~7.4.3 看查表、mask 和 pooling;如果排序看似相似却不相关,回到 7.5.1~7.5.3 看向量几何和阈值;如果总体分数漂亮但用户不满意,回到 7.6~7.8 看基线、切片、泄漏和回归;如果流程无法重跑,回到本节检查版本和产物。
学习资料的价值不只在“记住一个 API”。你要逐渐养成一种工程化阅读方式:每个概念都有输入、输出、假设、可观测证据和失败边界。带着这五个问题进入 Transformer,注意力、位置编码和预训练就不再是孤立名词,而会成为表示链路中可以被实验和评估的组件。
用 5 分钟向同学说明:数据从哪里来、怎样切块、Token 和向量如何产生、检索如何排序、哪个指标最重要、一个失败案例是什么、下一步最想验证什么。把答辩要点写进 README。
| 层次 | 你应能做到 | 自测证据 |
|---|---|---|
| 文本 | 解释编码、规范化、Token 和长度差异 | 文本诊断脚本 |
| 表示 | 建立词表、mask、Embedding 和 pooling | 形状/往返测试 |
| 任务 | 完成分类基线与两阶段检索 | 预测、排序和证据卡 |
| 评估 | 识别泄漏、切片错误并生成报告 | gold、audit、report |
| 工程 | 用版本、缓存和 smoke test 复现 | README、manifest、tests |
| 迁移 | 说清 Transformer 要解决什么问题 | 下一章问题清单 |
官方参考阅读
想继续查原始定义,可阅读 Python Unicode HOWTO,理解 Unicode 与编码;阅读 PyTorch nn.Embedding API,核对查表层的参数和 padding_idx;阅读 PyTorch Data Tutorial,把本章的文本 Dataset 与批处理契约迁移到官方数据管道。
官方文档是查定义和边界的地方,本章正文负责把它们组织成零基础可以运行的实验。遇到版本差异,以你当前环境的官方文档和版本号为准,并把差异写进项目 manifest。