←返回大模型学习资料
学习资料第 7 章 · 文本与表示

CHAPTER 07 · TEXT & REPRESENTATION

文本
与表示

文字进入模型之前,要经过字符、规范化、分词、词表和向量化。文字变成向量之后,还要知道相似度如何计算、检索结果如何解释、分类模型怎样评估。本章从零开始把自然语言处理的第一条可复现链路搭起来:读入文本,生成 Token,映射为 ID,查表得到 Embedding,完成相似度、文本分类和语义检索。

难度:基础进阶建议:先做小语料代码:Python 3.11+ / PyTorch小节:27 张独立学习卡片
TEXTBOOK MAP · TEXT & REPRESENTATION

本章学习地图

从一句文本走到可解释的向量检索。

阅读方式:先按大节顺序学习;需要回看时从地图跳到具体小节。页面会把每个大节显示为一张独立“书页”,底部的上一节/下一节只在大节之间翻页。

本章的判断标准:不是“分词器返回了一串数字”就算学会,而是能说明原文、Token、ID、mask、向量、相似度和评估指标之间的关系;能固定词表与版本;能从一个错误检索结果回到原文和索引;能用小语料写出可复现的文本分类或语义搜索实验。

连续项目 1/3 · 文本变成 Token

07_text_to_tokens.ipynb 建立字符词表、编码/解码和下一个 Token 训练样本。这里产生的数据合同会被第 8、9 章原样复用,不是做完即丢的一次性示例。

查看连续项目顺序

01 · CORPUS FOUNDATIONS

文本与语料:先弄清楚模型看到的是什么

自然语言不是一串“看起来像文字的字符”那么简单。它有 Unicode 字符、字节编码、空白、标点、结构和语境。本节从 Python 的 str 与 bytes 开始,建立清洗、切分、去重、切分数据和任务定义的完整基础。

在提交数据前做一次“反向追问”

看到一条处理后的记录,要能反向回答:它来自哪个文件和字符区间?使用了哪个编码和清洗版本?为什么属于这个 split 和标签?如果答案需要凭记忆补全,说明数据合同还不完整。把这些问题写成检查函数或清单,能让后续的 Token、Embedding 和评估建立在可信输入上。

语料治理也包括删除流程:当来源撤回或用户要求删除时,记录 ID 应能定位原文、chunk、词表统计、向量索引和缓存。学习项目先用 manifest 模拟这条链路,生产项目再接入实际存储和权限系统。可追溯不只为了出错时找原因,也为了知道哪些内容不该继续被模型看到。

把“读不出来”当成数据质量信号

解码失败、空文本、异常控制符和无法识别的许可证,都应进入质量报告而不是被静默跳过。报告记录文件、错误类型、字节数、处理动作和是否需要人工复核;这样下一次采集器升级时,可以比较失败率是否真的下降。对公开资料,保存来源链接和抓取日期也有助于核对内容是否发生变化。

表 7.1G · 进入下游前的最小断言
断言理由
text 是非空 str避免把 None、数字或错误对象送入分词器
raw_hash 与 source 唯一避免同一内容被重复计数
split 已固定避免处理后才临时泄漏
license 有值确认可以用于学习和展示

只要一条断言失败,就输出可定位的记录 ID。数据管道宁愿明确少几条样本,也不要用不透明的默认值制造“完整”数据集。

质量抽样:不用读完全部语料,也能及时发现问题

大语料不可能逐条人工阅读,但可以把质量检查设计成分层抽样。先按来源、语言、长度、时间和标签分桶,再从每个桶抽取固定数量;同时保留极短、极长、空值、含异常字符和高频重复样本。抽样报告记录随机种子、桶大小和抽到的 ID,下一次运行才能复现同一批样例。

缺失值要按字段分别处理。缺失标题可能仍可用于分类,缺失正文通常不能用于检索;缺失标签可以进入待标注队列,不能默认为“其他”。对每种缺失决定“保留、填充、删除、待复核”,并记录数量和比例。比例随时间变化时,可能是上游采集器改变,而不是模型突然变差。

表 7.1F · 语料抽样的分层维度
分层例子应关注的风险最小报告
来源网页、手册、用户工单风格和授权混合每源数量/缺失率
长度≤16、17~128、>128 Token截断和上下文不足分桶指标
时间月份或版本概念漂移时间分布
语言中文、英文、中英混排分词和编码差异语言切片
标签类别/待复核少数类与噪声支持数
PYTHON · sample_quality.py
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 换行、混合全角半角、零宽空格、不可见控制符、复制网页带来的不间断空格,以及看起来相同但码点不同的汉字。对每种样例写输入、期望输出和是否保留原文的说明。测试不是为了追求“所有文本都被洗得一样”,而是为了证明规则只在授权范围内改变文本。

表 7.1E · 语料流水线的审计字段
阶段推荐字段应回答的问题
读取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样本能否用于这个任务
PYTHON · load_records.py
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 写进每条记录,比在脚本末尾临时切片更不容易出错。

CORPUS LAB 7.1.4制作一份语料审计表

收集 8 个公开文本样例,为每条记录填写原始哈希、编码、字符数、规范化版本、来源、许可、group_id 和 split。故意加入一个 BOM 文件、一个空文件和一个近似重复文件,观察审计报告能否指出它们。

空文件应被标记而不是生成“空内容”样本;BOM 文件要记录实际读取策略;近似重复至少保留两条 ID 和判定依据。审计表本身也要保存生成时间和脚本版本。

7.1.1 Unicode 与文本对象:字符、字节和字符串不要混为一谈

在 Python 里,str 表示 Unicode 文本,bytes 表示一串字节。中文、英文、表情和组合字符都可能占用不同数量的字节,但在字符串层面仍可按 Unicode 码点处理。文件读取时指定 encoding="utf-8",是为了告诉 Python 怎样把字节解码成字符;写文件时重新编码,才得到可以传输或保存的字节。若把编码问题推迟到模型阶段,最后看到的往往是乱码、替换字符或长度异常。

“一个字符”也不总等于“一个用户感知的字”。例如带重音符号的文字可以由一个预组字符组成,也可以由基础字符和组合符号组成;某些表情由多个码点组合。对搜索和去重而言,是否做 Unicode 规范化要根据任务决定。规范化有助于把视觉上相同的表示统一,却也可能改变原文细节。保存原文、副本和处理版本,是可追溯文本管道的基本做法。

文本对象还可能来自不同容器:网页响应是 bytes,JSON 解码后是 str,表格单元格可能是空值或数字,聊天记录则常常包含角色、时间和附件字段。进入分词器之前,先定义统一记录结构,例如 {"id": ..., "text": ..., "label": ..., "meta": ...}。不要让下游代码猜测某一列是否已经转成字符串,也不要把缺失文本悄悄转换成字符串“nan”。

图 7.1 · 文本从磁盘到模型的表示层自制示意:每一步都可能改变长度和信息
bytes文件/网络
→
strUnicode 文本
→
规范化规则版本
→
Token分词器
→
ID/向量模型输入

同时保存原文和处理后文本,才能解释一次错误到底发生在哪一层。

表 7.1A · 文本对象的边界
对象示例适合做什么常见错误
bytesb"\xe4..."读写、传输、哈希直接按字符切片
str"人工智能"清洗、显示、规则处理误以为长度等于字节数
code pointord("智")诊断 Unicode 差异当成语义单元
record文本+标签+ID数据集与审计丢失来源和版本
Token子词 ID 序列模型输入与计费与字符数混用
PYTHON · text_objects.py
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 和失败原因。这样发现“某类文本的召回率突然下降”时,可以回到原文判断是不是编码、空白或清洗规则改变,而不是先怀疑模型。

如果数据来自用户输入或网页,原文可能含有隐私、脚本或恶意提示。学习项目至少记录来源和授权范围,展示样例时脱敏;生产项目再加入访问控制、内容安全和删除机制。数据工程的第一原则是知道自己保存了什么。

CORPUS LAB 7.1.1区分字符、字节和 Token

选一句含中文、英文、表情和换行的文本,分别打印 UTF-8 字节数、Python 字符串长度,并用任意分词器记录 Token 数。解释三个数字为什么不同。

字节数由编码决定,str 长度按 Unicode 码点计数,Token 数由分词规则和词表决定。答案应同时记录编码、分词器名称与版本,并保留原文,不能只写三个数字。

7.1.2 清洗、规范化与切分:减少噪声,但不要抹掉任务信号

清洗不是“把所有奇怪字符删掉”。它是为任务定义一套可解释的变换:去除无意义的首尾空白、统一换行、处理重复分隔符、修复明确的编码替换,或者保留表情、标点和大小写作为情感信号。分类、搜索、生成任务对信息的需求不同。先写规则和理由,再实现函数;规则越隐蔽,越难判断模型学到的是内容还是清洗器的副作用。

Unicode 规范化常见 NFC、NFKC 等形式。NFC 主要合并等价表示,NFKC 还可能把兼容字符折叠成更标准的形式。大小写折叠适合不区分大小写的英文搜索,却可能破坏专有名词、代码或缩写。中文分词通常不依赖大小写,但中英文混排、数字和单位仍需要明确边界。对每条规则写一个正例和反例,避免“清洗成功”只是肉眼看起来更整齐。

切句、切段和切词也属于数据变换。按句号切分会遇到小数、网址和缩写;按换行切分会把表格或诗歌的结构打散;按固定字符长度切块会截断句子。分块要为下游任务服务:分类希望样本标签稳定,检索希望每块自洽且带上下文,生成希望保留顺序和边界。所有切分参数都写入配置和 manifest。

表 7.1B · 清洗规则的收益与代价
规则可能收益可能损失验证方式
strip 空白减少无效前后缀格式任务丢缩进对照原文首尾
统一换行跨平台一致表格边界改变统计行数/空行
大小写折叠搜索召回更稳专名、代码混淆专名测试集
NFKC兼容字符归一特殊格式被折叠前后差异清单
去标点简单词频更干净情感、代码、边界丢失任务指标对照
固定分块长度可控语义被截断块间上下文检查
PYTHON · normalize_text.py
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”函数。

图 7.2 · 清洗规则的决策顺序自制示意:先保留,再证明可以删除
识别任务分类/检索/生成
→
保留原文可回溯
→
单条规则正例/反例
→
指标对照收益/损失

没有证据支持的删除规则,先不要加入主流程。

清洗函数的易错点

不要用 split() 代替真正的句子切分,因为连续空格、换行和标点会改变结果;不要在训练集和测试集分别拟合清洗统计量,否则预处理本身会泄漏标签或分布信息;不要把错误编码的替换字符 � 当成普通内容静默保留。遇到异常文本时,记录原始 ID 和原始片段,单独进入失败样本表。

清洗版本一旦变化,就应改变 preprocess_version。缓存、词表和向量索引都要知道它依赖哪一版文本。否则你可能用新版规则生成查询,却拿旧版文档向量比较,最终把版本错配误判为模型效果。

CORPUS LAB 7.1.2为一条清洗规则写反例

选择“去标点”“大小写折叠”或“按句号切分”中的一项,写一个规则看似有益但会伤害任务的文本,并设计一个指标对照。

例如搜索型号时去掉连字符会混淆产品名;情感分类去掉感叹号可能损失信号;小数句号切分会制造错误句子。对照应固定模型、数据和 seed,只改变这一条规则。

7.1.3 语料质量与任务定义:先问数据能不能支持结论

文本任务的第一张表不是模型参数表,而是数据字典。它至少描述记录 ID、原始来源、文本字段、标签定义、时间、分组键、许可范围、缺失策略和 split。标签要能被两个人按照同一说明复核;如果“相关”“高质量”“有帮助”没有判定边界,模型指标就无法解释。数据字典可以很短,但每个字段都要有来源和示例。

去重不仅是对文本做 set。完全重复、规范化后重复、近似重复和同一来源的转载都可能造成泄漏。检索任务要防止查询与文档原文重合,分类任务要防止同一用户或同一文章跨 split。先按稳定 ID 和来源分组,再做文本哈希与相似度检查;发现重复时保存处理决定,而不是静默删除。

任务定义要写清输入、输出、决策时点和错误代价。文本分类输出一个标签还是多个标签?检索返回文档还是段落?是否允许“不确定”或“无结果”?正例和负例怎样采样?训练时能看到未来信息吗?这些问题决定 Token、词表、Embedding 和评估指标的设计。模型只是任务合同的一个实现。

表 7.1C · 语料质量检查清单
维度最低检查风险信号产物
完整性空文本、异常编码、字段缺失空样本仍进入训练quality_report.json
一致性标签映射、语言、格式同标签含义不同data dictionary
独立性ID/用户/来源不跨 split验证异常高split audit
重复性精确/规范化/近似重复测试原文出现在训练duplicate pairs
覆盖性长度、类别、时间分布线上样本不在训练分布histogram/table
授权来源和使用范围无法说明可用性source manifest
PYTHON · corpus_audit.py
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"}]))

审计报告不是为了让数据看起来“完美”,而是让限制显式可见。若某个类别只有很少样本,报告支持数;若数据来自公开网页,报告抓取时间和许可证;若文本长度超出模型上限,报告截断比例。训练前发现问题的成本最低,训练后才发现标签错位,往往意味着所有曲线都需要重做。

从任务卡到数据卡

任务卡回答“要预测或检索什么、谁使用结果、错误有什么代价”;数据卡回答“数据从哪里来、怎样清洗、有哪些偏差、允许怎样使用”。初学者可以各写半页,附上三条正例、三条负例和三条已知限制。它们会直接指导后续词表、采样、指标和错误分析,而不是文档末尾的装饰。

语言分布和领域分布也要进入数据卡。中文、英文、代码、数字、表情混在一起时,一个统一的清洗规则可能并不公平;不同长度、来源和时间段的样本也可能有不同难度。切分时保留这些分组字段,评估时再按组报告,才知道模型在哪些条件下失效。

CORPUS LAB 7.1.3写一张任务卡和数据卡

为“根据标题检索相关文章”或“判断文本是否包含技术问题”任选一个任务,写出输入、输出、正负例、错误代价、数据来源、切分键、三项限制。

必须能回答谁会用结果、什么情况算无结果、是否存在同源泄漏、数据是否允许使用以及未来如何更新。限制越具体越好,例如“只覆盖中文技术问答”“短标题占比过高”“未评估跨领域样本”。
表 7.1D · 本节完成标准
能力最低证据下一步
理解文本对象能解释 str、bytes、编码和 Token 的差别实现分词接口
写清洗规则每条规则有正例、反例和版本固定预处理
审计语料能报告空值、重复、分布和泄漏构建词表
定义任务能写输入、输出、错误代价选择表示与指标

02 · TOKENIZATION

Token 与分词:模型如何把一句话切成可处理的片段

模型通常不能直接接收自然语言字符串,而要先把文本切成 Token,再映射成整数 ID。本节比较字符、词、子词和字节级方案,解释特殊符号、分词长度和可逆解码,并写出一个带版本信息的分词器接口。

把 Token 数当作资源预算

同一模型的上下文窗口是有限资源,系统提示、历史对话、检索片段和待生成内容会共同占用它。统计平均值不够,还要看 P95 和最坏样例;否则大多数请求正常,少数长请求会突然截断关键证据。把 Token 预算写进配置并在运行时提示“舍弃了多少内容”,用户才知道答案为什么不完整。

分词规则改变后,任何依赖序列长度的组件都可能受到影响:padding 批次效率、位置编码上限、训练显存、检索块大小和评估切片。版本清单要把这些依赖串起来,不要只更新一个 tokenizer 文件。

分词器的最小测试矩阵

每次修改规则后,固定跑一张小矩阵:普通中文、含标点的中文、英文长词、数字版本、代码路径、空字符串、表情、连续空白和超长输入。对每个样例保存 tokens、ids、长度、特殊位置和 decode 结果。测试矩阵的目的不是证明分词器理解语义,而是防止输入边界在升级时悄悄改变。

表 7.2G · 分词回归的通过条件
检查通过条件失败处理
确定性相同文本/版本得到相同 ids检查随机或共享状态
长度长度报告包含特殊符号核对模板和预算
边界offset 不越界且可解释抽样回到原文
升级差异有 changelog重建依赖产物

如果差异是有意的,就提高版本并重新生成词表、Embedding 和索引;如果差异不是有意的,就在发布前阻断。分词器是模型输入的源头,源头不稳定,后面的指标都不稳定。

分词器调试的三个视角

第一个视角是表面覆盖:输入的每个字符是否都能映射到某个 Token,是否出现未知符号,中文标点和全角数字是否被意外拆散。第二个视角是长度效率:同一批样本的 Token/字符比、每个 Token 的平均字节数、不同语言的 P50/P95 长度。第三个视角是可逆性:decode 后的文字是否保留必要空格、换行、标点和大小写。三个视角一起看,才能判断分词器是在覆盖、压缩还是丢失信息。

子词合并的直觉可以用一个小语料手算:先把每个词拆成字符,统计相邻对出现次数,把最高频的一对合并,再重复若干轮。合并次数决定词表容量,语料频率决定哪些片段容易复用。真实算法会有边界标记、概率或评分细节,但手算能让你明白“词表不是从字典直接抄来的”,而是从数据分布中学习的压缩方案。

表 7.2F · 分词器回归样例
样例必须观察潜在回归
空字符串返回空序列或边界符的约定出现非法负长度
连续空格/换行decode 是否恢复任务需要的格式代码块缩进改变
中英数字边界和 token/字符比单位被拆得过长
罕见表情UNK 或字节回退策略静默丢失符号
超长文本截断方向、窗口数量、offset只保留无关尾部
PYTHON · token_budget.py
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,避免训练与推理使用不同约定。

表 7.2E · 分词粒度的诊断账本
输入现象字符级可能表现子词级可能表现要记录的证据
中文短句每字一个单位,长度直观常见词可能合并token 数/字符数比
罕见专名仍可逐字覆盖拆成多片或 UNKUNK 率、片段边界
英文长词字符很多词根和后缀可复用平均片段长度
中英数字混排边界稳定但序列长规则差异明显语言切片统计
表情/符号可逐码点拆分可能被特殊映射码点与 token 对照
PYTHON · inspect_tokens.py
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。

TOKEN LAB 7.2.4做一次长度预算实验

选三条中文、英文和中英混排文本,分别用字符、词和子词方案编码。设置最大长度 16,展示每种方案的截断位置、特殊符号和最终 mask,并解释哪种方案更适合你的任务。

至少报告 tokenizer/版本、是否加入特殊符号、截断方向和 Token 数。不能只说“子词更好”,要结合截断率、UNK/特殊符号和下游指标说明。

7.2.1 从字符到 Token:切分单位决定模型看到的世界

Token 可以是一个字符、一个词、一个子词片段,甚至是一到几个字节。不同分词方案在词表大小、序列长度、未知词比例和跨语言能力之间取舍。字符级分词几乎不会遇到未知词,但中文、英文和代码的序列可能很长;词级分词语义直观,却容易因新词、拼写变化和领域术语产生大量未知词;子词通常在这两者之间取得平衡。

“分词”不等于简单的 text.split()。空格在中文里不是可靠边界,标点、数字、网址、代码、表情和混合语言还需要单独处理。现代分词器常把空格信息编码进 Token,或者使用前缀标记来区分词首;因此解码时必须由同一个分词器恢复原文,不能手工把 Token 用空格拼回去。

学习时先观察同一句话在多种方案下的长度和片段。长度影响显存、训练步数和上下文窗口,片段边界影响模型是否容易学到词根、词缀、数字单位和代码符号。不要只问“分得准不准”,还要问“这个任务需要哪些边界信息”“长文本会不会被大量切碎”“多语言文本是否公平”。

图 7.3 · 同一句话的三种切分视角自制示意:粒度越细,长度通常越长
字符级我 / 爱 / AI
→
词级我爱 / AI
→
子词级我爱 / A / I
→
ID 序列整数输入

没有绝对最优粒度,只有与任务、语料和预算相匹配的选择。

表 7.2A · 分词粒度比较
方案词表规模序列长度优势代价
字符级小长几乎无未知词长距离依赖难
词级大短片段直观新词/拼写变化多
子词级中中平衡覆盖与长度边界不总直观
字节级很小可能很长编码覆盖广语义片段被拆细
PYTHON · token_length_probe.py
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 里。

TOKEN LAB 7.2.1做一张 Token 长度统计表

准备十条短文本,覆盖中文、英文、数字、URL、代码和表情。用字符列表、空格切分以及一个子词分词器分别统计长度,记录最短、P50、P95 和最长样本。

表中应写清分词器名称/版本、长度单位和样本类型。不要只报告平均值;长尾样本会决定截断、batch size 和推理预算。

7.2.2 子词与特殊符号:用有限词表覆盖无限的新文本

子词分词的核心想法是从语料中学习一组常见片段,再用这些片段拼出没有见过的词。BPE 反复合并高频相邻片段,WordPiece 也以子词词表为基础但使用不同的训练目标,Unigram 则从候选片段中选择概率更高的分解。学习这些名字的重点不是背算法细节,而是理解:词表由训练语料决定,分解结果由词表和规则共同决定,换一个 tokenizer 就可能得到完全不同的 ID 序列。

特殊 Token 是序列协议的一部分。常见的有序列开始、序列结束、未知、填充、分隔和掩码符号。它们各自承担不同职责:[PAD] 让不同长度的序列组成 batch,[UNK] 表示词表无法覆盖,[CLS] 或自定义起始符号可作为分类汇聚位置,[SEP] 区分两段文本。特殊符号的 ID 必须固定并写进 manifest,不能每次运行随机分配。

多语言与代码文本会暴露词表的偏差。一个对英文优化的词表可能把中文拆得很碎,对中文优化的词表也可能无法高效表示长英文单词和代码标识符。比较分词器时,按语言、领域和文本类型分组报告长度、未知率和字符覆盖,不要用一条英文句子代表“整体效果”。

表 7.2B · 特殊 Token 的职责
Token作用进入 loss 吗常见误用
PAD补齐 batch通常用 mask 忽略当成真实词训练
UNK未知片段兜底视任务保留默默丢弃原文
BOS/CLS序列开头或汇聚依模型目标位置约定不一致
EOS/SEP结束或分隔生成任务常需要截断后缺结束符
MASK掩码训练占位由目标决定推理时随意加入
PYTHON · special_tokens.py
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 不足以恢复完整行为;同一词表配不同的预处理或合并规则,编码结果仍可能不同。

图 7.4 · 特殊 Token 如何参与 batch自制示意:PAD 只为形状服务,mask 决定是否计入
短序列BOS a EOS
→
补齐BOS a EOS PAD
→
attention mask1 1 1 0
→
loss mask忽略 PAD

形状对齐和语义计数是两件事,不能只依赖 padding 值。

特殊符号的三条纪律

第一,ID 固定:训练、验证、推理和保存加载使用同一映射;第二,语义固定:PAD 不代表空文本,UNK 不代表删除,EOS 不等于截断;第三,统计固定:长度、注意力和 loss 是否包含特殊符号,要在指标定义中说明。若新增一个特殊符号,词表版本、Embedding 行数和 checkpoint 都应一起升级。

检查一条编码结果时,优先打印“Token 文本 + ID + mask”三列。只看 ID 很难发现 BOS/EOS 位置反了、PAD 被放在前面或 SEP 丢失等问题。把这三列保存到小型 JSON 样例,是最便宜的回归证据。

TOKEN LAB 7.2.2设计特殊 Token 协议

为“单句分类”和“问句+文档检索”分别设计一套特殊 Token,写出它们的顺序、ID、attention mask 和 loss mask。

单句分类可用 BOS/CLS+文本+EOS,检索对可用 BOS+问题+SEP+文档+EOS。PAD 只用于补齐,attention mask 标出真实位置;分类 loss 通常只看标签,不把 PAD 当文本目标。

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 是否与配置一致。任何一项不匹配,都应在启动时失败而不是输出看似合理的结果。

表 7.2C · Tokenizer 接口合同
接口输入输出必须声明
encode一条 strids / mask / offsets特殊符号、截断
decode一串 intstr跳过特殊符号规则
batch_encodestr 列表二维 ids/maskpadding 方向
vocab_size无整数是否含特殊符号
metadata无版本字典文件哈希与配置
PYTHON · tokenizer_contract.py
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。

TOKEN LAB 7.2.3写一个分词器回归表

为五类样例记录 encode、decode、长度、特殊符号和版本。故意修改一个词表 ID,观察哪几项测试失败,并写出启动时应该阻止的错误。

至少应发现固定 Token 的 ID 变化、Embedding 行数不匹配或 decode 差异。启动时检查词表哈希、特殊 ID、vocab_size、最大长度和固定样例输出,失败就拒绝加载。
表 7.2D · 本节完成标准
能力最低证据下一步
比较粒度能解释字符、词、子词和字节的取舍选择词表
理解特殊符号能写出 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 权重可能需要重新初始化或蒸馏。

表 7.3F · 词表评估的三个曲线
曲线/统计它说明什么异常时先查
频次—排名词表是否被少数高频片段占满清洗、采样和域混合
Token/字符比不同语言和长度的压缩效率分词边界、特殊符号
UNK 率词表覆盖未知输入的能力词表版本、输入编码
有效长度占比PAD 和截断的浪费batch 策略、max_length
ID 分布是否存在异常空洞或冲突序列化和迁移脚本
PYTHON · validate_vocab.py
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 和错误分析就无法区分。

表 7.3E · 词表与批处理的固定字段
字段含义验证失败表现
vocab_version词表生成规则和语料版本manifest 能回指旧 ID 被误读
token_to_id编码方向ID 唯一且为整数覆盖或排序不稳定
id_to_token解码方向长度等于词表容量显示错误片段
special_idsPAD/UNK/BOS/EOS 等不与普通 Token 冲突mask/loss 错位
max_length批次和模型的长度上限截断测试运行时越界
padding_side左填充或右填充mask 与位置对齐最后 Token 取错
PYTHON · encode_records.py
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 都纳入测试。

VOCAB LAB 7.3.4检查一份词表是否可迁移

手工创建包含 4 个特殊 Token 和 8 个普通 Token 的词表,保存后重新加载。验证双向映射、特殊 ID、三条未知文本、左/右填充和截断后的 EOS 位置。

需要明确 padding_side 和 truncate_policy;加载后普通 Token 的 ID 必须与保存前完全一致。测试应检查 mask 的 0/1 位置,而不只是打印列表。

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 写一个不可变的测试。

图 7.5 · 词表映射的双向通道自制示意:模型只看 ID,但人要能反查 Token
Token“检索”
→
token_to_id57
→
Embedding 行第 57 行
→
id_to_token可解释

只保存整数列表而不保存词表,等于丢失了输入协议。

表 7.3A · 词表字段与职责
字段含义示例不一致后果
token_to_id片段到整数“检索”→57模型输入改变
id_to_token整数到片段57→“检索”decode/日志错误
vocab_sizeEmbedding 行数4096索引越界
unk_id未知兜底1未知被误当 PAD
pad_id补齐占位0loss 统计污染
version词表行为版本vocab-2026-08无法复现
PYTHON · build_vocab.py
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,未来才能解释一次词表差异。

VOCAB LAB 7.3.1构建一个可审计词表

准备一组包含重复、低频和特殊字符的训练文本,设置 min_freq 与 max_size,输出 Token 频率、ID、未知率估计和词表 JSON。验证四个特殊 ID 固定不变。

词表应只由训练文本统计,频率相同的 Token 有确定排序;JSON 中同时保存正向映射、特殊 Token、参数和版本。验证时检查 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 三列,通常比直接打印一行长整数更容易发现错位。

表 7.3B · 不同任务的序列张量
任务input_idstarget/lossPAD 处理
句子分类整句 ID一个类别attention mask
语言模型前缀序列右移一位的 ID无目标位置忽略
序列标注每个 Token逐 Token 标签label PAD 用 ignore
双句匹配SEP 分隔两句一个匹配标签mask 标出真实段
检索编码查询/文档独立相似度或正负对每段分别 padding
PYTHON · pad_batch.py
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 阶段暴露。

图 7.6 · 四张序列表的对齐关系自制示意:同一位置可能有不同的计数规则
input_ids模型输入
→
attention_mask看哪些位置
→
target_ids预测什么
→
loss_mask计入哪些位置

出现 PAD 不代表四张表都应写成 0;每张表有自己的语义。

mask 的易错点

attention mask 的 0 通常表示不参与注意力,loss mask 的 0 表示不计入损失,不能因为名字相似就复用而不检查目标。若用 ignore_index,确认损失函数真的配置了同一个值;若手工加权,确认分母是有效位置数而不是总 padding 长度。验证一个 batch 时,把每一行的真实长度、PAD 数和有效目标数一起打印。

空文本是最容易暴露边界的样例。加入 BOS/EOS 后仍应有可处理的长度;全 PAD 序列不应让 mean loss 除以零;超长文本截断后要决定是否保留 EOS。把这些样例放入回归测试,避免正常句子掩盖协议错误。

VOCAB LAB 7.3.2画出 input 与 target 对齐表

用序列 [BOS, 我, 爱, AI, EOS] 写出语言模型的 input_ids、target_ids、attention_mask 和 loss_mask,说明每个位置为什么计入或忽略。

target 通常是 input 向左移动一位,最后一个位置没有下一个 Token;BOS 是否计入取决于目标定义,PAD 必须忽略。答案应写明 shift 方向、EOS 策略和有效分母。

7.3.3 截断、填充与数据契约:长度预算要和任务一起设计

最大长度是一个工程预算,不是神奇常数。长度太小会截断关键信息,太大则增加显存和延迟。设置前先统计 Token 长度分布,观察中位数、P95、最大值和不同语言/领域的差异;再根据任务决定保留开头、结尾、标题、查询词或滑动窗口。分类通常需要完整关键信息,检索可以分块后分别编码,生成则要保留顺序和特殊边界。

固定长度 padding 易于批处理,却会浪费大量计算;按 batch 动态 padding 更节省,但 batch 顺序和长度分桶会影响随机性和吞吐。对长文本可按 Token 窗口切分并保留 overlap,避免实体或句子正好被截断。每个 chunk 保存原文 ID、起止偏移、窗口编号和是否完整结束,检索结果才可以回到上下文。

数据契约应在进入模型前完成:ID 范围、序列长度、mask 形状、dtype、padding 方向、特殊 Token 顺序和标签格式都可以断言。契约不是为了让代码啰嗦,而是为了让错误靠近源头。训练、验证、推理共用同一套编码器,只有任务允许的增强或截断策略不同。

表 7.3C · 长度策略选择
策略适合优点风险
固定最大长度简单基线形状稳定padding 浪费
动态 padding长度差异大省计算吞吐波动
长度分桶大批量训练减少填充采样复杂
滑动窗口长文检索/标注保留上下文重复计算
首尾拼接只关心两端简单保留重点中间信息丢失
PYTHON · sequence_contract.py
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;查询端和文档端只要有一项不一致,就在启动时提示。这样索引重建是一次明确的迁移,而不是悄悄得到一批不可比较的向量。

VOCAB LAB 7.3.3为长文本选长度策略

给一个平均 80 Token、P95 260 Token 的技术文档集合,分别为分类和检索任务选择长度策略,写出最大长度、截断/窗口规则和需要记录的字段。

分类可先比较动态 padding 与固定上限,并检查被截断样本;检索可使用带 overlap 的窗口,保存文档 ID、offset、窗口号和参数版本。选择必须用验证指标、长度分布和资源账本支持。
表 7.3D · 本节完成标准
能力最低证据下一步
构建词表能固定特殊 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 不变,可能是分类目标与检索目标不一致。

表 7.4G · Embedding 训练诊断
观察项正常信号异常时先查
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 或空文本,规定返回零向量、特殊向量还是拒绝,并在下游相似度中处理零向量。边界约定比某一种池化名称更重要。

表 7.4F · 池化方案的假设
方案保留什么容易失去什么适合先做吗
mean整体平均语义否定、顺序、关键短语最适合基线
max每维最强激活出现位置和频率适合对照
first valid聚合位置上下文依赖训练约定需确认模型
attention pooling可学习重要性解释和过拟合风险基线稳定后
加权 mean明确的规则权重权重设计偏差便于做消融
PYTHON · pooling_contract.py
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 当普通词求平均,短文本会被大量零向量或固定向量稀释,长短样本的比较会产生系统偏差。

表 7.4E · Embedding 实验的形状账本
对象典型形状含义应检查什么
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]每个类别的未归一化分数与标签形状对齐
PYTHON · embedding_shapes.py
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。

EMBEDDING LAB 7.4.4比较三种池化

用同一批 ID 和 mask,分别实现平均、max 和首个有效 Token 池化。构造一条加入 PAD、另一条不加 PAD 的等价文本,验证两者的句向量是否保持一致。

平均池化要按有效 Token 数除法,max 池化要把 PAD 位置设为负无穷或排除,首 Token 方案要明确取哪一个位置。等价文本加 PAD 后结果不应因为 padding 数量改变。

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 复用。

图 7.7 · Embedding 查表过程自制示意:每个 ID 只选择一行,训练再改变行的数值
input_ids(B,L)
→
Embedding 表(V,D)
→
向量序列(B,L,D)
→
mask 汇聚(B,D)

Embedding 负责查表,句子语义还需要汇聚或上下文建模。

表 7.4A · Embedding 输入输出契约
输入形状输出检查
单个 ID()(D,)long、范围
批量 ID(B,)(B,D)batch 对齐
序列 ID(B,L)(B,L,D)Token 轴
词表V表第一维 V与 vocab_size 一致
padding_idx一个 ID对应行不更新汇聚需 mask
PYTHON · embedding_probe.py
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、词表、训练目标和模型版本。

EMBED LAB 7.4.1验证 Embedding 的形状

创建一个词表大小 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 让同标签样本在决策边界的一侧,检索的对比损失或正负对让相关查询与文档相近,不同任务的梯度方向不同。随机初始化的向量没有稳定语义;训练后要用固定词对、句对或检索样例观察变化,不能把初始化图当作“语义空间”。

表 7.4B · 序列汇聚方法
方法计算优点风险
masked mean有效向量和/有效数简单、稳定词序信息弱
masked max有效位置逐维最大突出强信号梯度稀疏
first token取首位置接口简单需训练汇聚位
attention pool学习位置权重可强调关键 Token参数和解释复杂
加权平均自定义权重求和可纳入词频/质量权重偏差
PYTHON · masked_pooling.py
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,返回零向量只是一个选择,也可以抛出异常;关键是把选择写清楚。若向量已经做了归一化,先池化再归一化和先归一化再池化的结果不同,实验报告要记录顺序。

图 7.8 · mask 汇聚的分母自制示意:有效 Token 数决定平均值的尺度
向量序列B×L×D
→
扩展 maskB×L×1
→
求和忽略 PAD
→
有效数相除B×D

分母错误会让不同长度的句子出现不可比的向量尺度。

训练表示时记录什么

每次训练至少记录 Embedding 维度、词表大小、padding 策略、汇聚方式、loss 定义、正负样本构造和向量归一化。若使用冻结的预训练 Embedding,记录是否允许微调;若只训练分类头,向量空间不会因标签数据改变。冻结与解冻是一个实验变量,不能混在同一运行里。

观察向量变化可以抽取固定 Token 的范数、相似邻居和若干句对的分数。它们不是最终指标,却能帮助发现 Embedding 没有更新、PAD 行被污染或梯度只集中在高频 Token 等问题。

EMBED LAB 7.4.2比较两种池化

对同一批带 PAD 的序列分别做普通 mean 和 masked mean,列出一个短句的结果差异,说明为什么普通 mean 会改变向量尺度。

普通 mean 把 PAD 位置也放入分母,短句有效信息占比更小;masked mean 只按有效 Token 求和与计数。对全 PAD 样本要提前定义返回值或错误。

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 归一化,分类模型则可能依赖原始尺度。不要跨实验直接比较向量距离,先确认归一化、温度、池化和训练模式完全一致。

表 7.4C · 表示链的形状与位置
阶段典型形状新增信息常见错位
Token ID(B,L)离散片段float ID
Token Embedding(B,L,D)片段向量词表行数不匹配
位置表示(1,L,D) 或同形顺序超过 max length
上下文表示(B,L,D)邻近 Token 交互mask 未传入
句子向量(B,D)固定长度表示PAD 污染
PYTHON · position_probe.py
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、池化和相似度,可以在几秒内发现协议错误;直接上大模型时,同样的错误会被显存、下载和长时间训练掩盖。

EMBED LAB 7.4.3定位顺序信息丢失

构造两句 Token 集合相同但顺序相反的文本,比较平均 Embedding;再加入位置 Embedding,说明哪些信息发生改变、哪些信息仍然没有被建模。

无位置的 mean pool 对顺序不敏感;加入位置后每个位置向量不同,但仅相加仍未充分建模 Token 间关系,需要上下文层才能表达复杂顺序。
表 7.4D · 本节完成标准
能力最低证据下一步
查表能解释 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 时,可以判断是模型更新、精度变化、文本处理还是排序规则造成的,而不是凭截图猜测。

表 7.5H · 相似度实验记录
字段示例用途
query_id/candidate_idq-03 / c-17定位原文与排名
raw_score0.734812保留计算精度
metriccosine_l2说明分数定义
labelrelevant / hard_negative连接评估指标
decisionkeep / reject / review说明阈值结果

实验报告还要标出候选池大小和计算成本。相同 Recall 下,减少一半候选或延迟下降 30% 可能比微小的分数提升更有价值。把每次实验的参数和结论写在同一行,未来换成 Transformer 句向量时仍可直接比较。

最后提醒自己:相似度高的文本可能共享模板、标题或关键词,却没有回答同一个问题。把“同主题但方向相反”“同 API 的不同版本”“引用错误结论”加入 hard negative,才能让检索评估接近真实使用。

阈值之外还要决定“返回几条”

Top-k 与阈值解决不同问题:k 限制页面最多显示几条,阈值限制结果最低需要多相关。两者同时使用时,先过滤还是先取 k 会改变结果,必须固定顺序并写入测试。若只有一条结果超过阈值,页面应显示一条并说明候选不足,而不是补上低分内容凑满 k。

表 7.5G · 排序参数的组合
策略优点风险
只取 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,才能解释为什么页面只出现两条。

表 7.5F · 向量检索的边界样例
样例预期行为断言
查询维度错误在计算前失败错误说明 q/d 维度
候选为空返回空列表和原因不调用 argmax
零向量拒绝或显式降级无 NaN 分数
同分候选顺序稳定多次运行 ID 一致
重复文档块按规则去重/合并保留原始与最终 rank
PYTHON · deterministic_topk.py
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 做稳定的次序打破平局。

表 7.5E · 相似度决策表
问题先检查推荐记录不要直接做什么
分数都很高是否归一化、向量是否塌缩范数分布、分数直方图盲目提高阈值
排序波动tie-break、设备和精度候选 ID、seed、dtype只保留一次结果
空结果过多阈值、查询长度、OOV无结果原因降低阈值到没有边界
近义词不命中训练域、负例、模型目标失败查询对把余弦换成魔法数字
大批次变慢矩阵形状、索引策略候选数、延迟分位数只看平均耗时
PYTHON · stable_cosine.py
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,还要在排序后做文档级去重,并报告去重前后的候选数量。

阈值不是模型常数,而是任务决策点。用验证集画分数与相关性分布,选择满足召回、误报或人工审核成本的阈值;测试集只用于最后报告。对于不同查询长度、语言或来源,最优阈值可能不同,但分组阈值会增加维护复杂度,先确认总体阈值是否已经足够。每一次阈值改变都应产生新的配置和回归报告。

硬负例能帮助你发现“高相似但不相关”的边界:同一主题的错误版本、相反操作、相邻章节和只共享模板的文本。把这些样例加入验证或训练要谨慎,不能把测试答案泄漏进模型。相似度实验最终要回到人工可理解的失败类型,而不是只优化一条曲线。

SIM LAB 7.5.4校准一个可解释阈值

准备 20 对查询—候选,标注相关/不相关,计算余弦分数。尝试三个阈值,分别记录 Recall、误报数、空结果率和最容易被误判的硬负例。

阈值选择要在验证数据上完成,并说明目标偏向召回还是精确。展示分数分布和至少两条边界案例,不能只挑一个“看起来合适”的数字。

7.5.1 向量几何直觉:长度、方向和坐标都要谨慎解释

向量可以表示为高维空间中的一个点或一条从原点出发的箭头。它的范数描述长度,方向描述各维度相对比例。两个文本向量可能长度相近但方向不同,也可能方向相近但长度差异很大。若任务关注语义方向,通常会先做 L2 归一化;若长度本身携带置信度或频率信息,盲目归一化则可能损失信号。

点积是对应坐标相乘再求和,既受方向影响也受长度影响。余弦相似度是点积除以两个向量的范数,主要比较方向,范围通常在 -1 到 1。欧氏距离比较坐标差异,曼哈顿距离比较绝对差异;没有一种距离天然适合所有表示,必须结合训练目标和数据验证。

高维空间中的“最近”是相对候选集合而言的。把查询与十篇文档比较得到的第一名,不等于与一百万篇文档比较时仍然第一名;向量分布、重复文档、长度和领域都会影响排名。报告检索结果时保存候选 ID、原文、分数、归一化方式和索引版本,才能回溯。

图 7.9 · 向量比较的两条因素自制示意:方向和长度会共同影响点积
文本向量坐标
→
范数长度
→
归一化方向
→
相似度候选排序

先说明比较空间,再解释分数高低。

表 7.5A · 常见向量度量
度量公式直觉受长度影响适合先做什么
点积对应维度乘积求和是神经网络打分
余弦点积/范数乘积较少语义方向比较
欧氏距离坐标差的平方和开根是同尺度空间
曼哈顿距离坐标差绝对值求和是稀疏/鲁棒对照
负距离把距离转成越大越好依距离统一排序接口
PYTHON · vector_geometry.py
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 重叠率和任务指标一起报告,才能决定哪种比较更合适。

VECTOR LAB 7.5.1手算一个余弦相似度

对向量 (3,4) 与 (0,5) 手算点积、范数和余弦相似度,再说明为什么把第二个向量长度扩大后,点积和余弦的变化不同。

点积为 20,范数为 5 和 5,余弦为 0.8;把第二个向量同方向放大,点积会放大,余弦保持不变。答案要说明是否先归一化。

7.5.2 余弦与距离:批量计算、Top-k 和阈值都要写清

批量相似度通常把查询矩阵写成 (Q,D)、文档矩阵写成 (N,D),结果是 (Q,N)。第一个轴对应查询,第二个轴对应候选文档;对每一行取 Top-k,才能得到每条查询自己的排名。排序方向取决于度量:相似度越大越好,距离越小越好。统一成一个“分数越大越好”的接口,可以减少下游分支。

Top-k 不是阈值判断。Top-k 总会返回 k 个结果,即使所有候选都不相关;阈值则允许返回“无结果”,但需要验证集选择阈值并考虑分数分布变化。实际系统常先用较大的 k 召回候选,再由更精细的模型或规则重排,最后应用阈值和权限过滤。

数值边界包括零向量、NaN、极大范数、重复候选和空候选集。归一化零向量时要决定返回零向量还是报错;空候选集返回空列表而不是索引越界;分数相同时要有稳定的二级排序,例如文档 ID。确定性排序对回归测试非常重要。

表 7.5B · Top-k 接口字段
字段含义示例易错点
query_id查询身份q-001批次下标代替 ID
candidate_id候选身份doc-042向量行号失效
score相似度/负距离0.83方向未统一
rank候选名次1从 0/1 混用
threshold是否可接受0.62在测试集反复调
index_version候选快照版本idx-v3查询/文档不一致
PYTHON · topk_search.py
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 合并。

图 7.10 · 召回、过滤与重排自制示意:Top-k 只是中间候选集
查询向量Q×D
→
粗排相似度 Top-k
→
过滤权限/阈值
→
重排更精细模型
→
结果原文+分数

每层都保存候选 ID,方便解释为什么某文档最终出现。

阈值不是模型的永久属性

阈值与分词器、Embedding 模型、语料领域和索引分布绑定。模型升级或文档增加后,分数分布可能改变,旧阈值不能直接沿用。用独立验证集选择阈值,报告 precision/recall 变化和无结果率;上线后监控分数分布漂移。

权限和业务规则应在候选过滤层明确处理。一个相似度最高但用户无权查看的文档不应被返回;过滤后结果不足 k 时,接口要能返回少量或无结果,而不是补回不合规候选。

VECTOR LAB 7.5.2实现稳定的 Top-k

写一个函数输入查询向量、文档向量、文档 ID 和阈值,返回按分数降序、ID 升序的结果。测试空候选、k 大于文档数、零向量和没有结果四种情况。

排序方向、二级排序、阈值时机和空列表都要明确。零向量应采用稳定策略,如返回零分并记录警告,不能产生 NaN 后继续排序。

7.5.3 相似度实验:用小样本验证表示空间是否有用

相似度实验不需要一开始建立百万级索引。先准备几十条文档和查询,人工标注相关、部分相关和不相关,记录同义改写、关键词重叠、否定句、数字差异和跨领域样例。用不同的 tokenizer、池化、归一化和模型生成向量,比较 Top-k 重叠、相关率、无结果率和错误类别。

对照实验要固定候选集合和文档 ID。若只更换 Embedding 模型,清洗、切块、查询、Top-k 和阈值保持一致;若更换切块策略,则要重建文档向量,并标记 index_version。结果表不仅写平均分数,还要展示一条成功检索、一条相似但错误、一条完全找不到的案例。

向量可视化可以帮助提出假设。用 PCA、随机投影或其他降维方法画出类别和近邻,观察同义句是否聚在一起、长文本是否因长度聚类、某一领域是否形成孤岛。降维图不是指标,最终仍要回到人工标注和任务评估。可视化的价值是告诉你下一步该看哪一批错误。

表 7.5C · 相似度小实验记录
变量固定项记录指标示例结论
Tokenizer模型/块/候选长度、召回中文长尾变短
PoolingTokenizer/模型Top-k、分数标题权重影响排名
Normalize其余不变排名重叠率点积偏向长向量
Chunk size查询/模型Recall@k、无结果率块太短缺上下文
阈值候选与分数精确率、覆盖率阈值提高减少噪声
PYTHON · similarity_eval.py
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 和分数,再逐层比较成功样例。

如果相似度高但人工认为不相关,检查关键词共现、模板化文本和重复来源;如果相似度低但语义明显相关,检查同义表达、领域术语和负例覆盖。错误类别比单纯增加维度更能指导下一次数据或模型改动。

VECTOR LAB 7.5.3写一份检索错误复盘

准备三条查询,各自列出人工相关文档、Top-3 结果、分数、是否命中和错误类型。选择一次只改变池化或切块的实验,说明预期影响。

复盘必须包含 query/document ID、相关粒度、分数口径、模型/索引版本和错误分类。改动只针对一个因素,比较 Recall@k、MRR、无结果率和资源成本。
表 7.5D · 本节完成标准
能力最低证据下一步
理解几何能解释范数、点积、余弦和距离批量排序
实现 Top-k能处理批次、阈值、空集和稳定排序向量索引
设计实验能固定候选、标注相关性和记录版本评估检索
复盘错误能回到原文、Token、向量和索引端到端项目

06 · TEXT CLASSIFICATION

文本分类:把表示、数据和训练循环接在一起

文本分类是检验表示是否有用的第一块实验场。我们用统一的 Token、词表和 mask 读取短文本,先做多数类与词袋基线,再训练 Embedding 平均池化分类器,最后分析类别不平衡、阈值和错误样例。

训练一轮之前,先画出标签和错误的边界

标签编码表应同时保存人类可读名称、整数 ID、定义和示例。不要按字母顺序临时生成 ID 后直接训练;新增类别、类别合并或标签改名都可能改变旧报告。多标签任务使用每个标签独立的 sigmoid 与二元损失,单标签互斥任务使用 softmax 语义的交叉熵,二者的数据格式和阈值策略不同。

学习率、batch size、epoch、class weight、dropout、seed 和 early stopping 都属于实验配置。训练日志至少记录每轮训练损失、验证损失、主要指标和最佳 checkpoint;只保存最后一轮,可能错过验证集表现最好的模型。测试集只在最终选择方案后运行,避免把测试结果当成调参反馈。

表 7.6F · 分类错误的诊断顺序
现象先看可能修复
训练/验证都低标签定义、输入形状、基线修数据或任务
训练高、验证低重复、数据量、正则化去重、扩充、降复杂度
多数类很高每类 recall、混淆矩阵权重、采样、补少数类
某长度桶差Token 长度、截断、padding调整预算/分桶
数字版本错Token 化、规范化、标签样例增加技术 hard cases
PYTHON · confusion_buckets.py
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 算子绝对确定;报告中写出设备和确定性设置。

表 7.6E · 分类器输入输出合同
对象形状/类型规则典型错误
input_idsLongTensor [B,T]每个值在词表范围float 或越界 ID
attention_maskLong/Bool [B,T]PAD 为 0,有效为 1与 ids 长度不同
labelsLongTensor [B]范围 [0,C)one-hot 传给 CE
logitsFloat [B,C]未 softmax 的分数先 softmax 再求 CE
predictionLongTensor [B]argmax 或阈值决策把概率当标签
PYTHON · train_one_epoch.py
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。把错误按“否定词、数字/版本、过短、过长、混合语言、标签边界、近似重复、无上下文”分桶,抽取每桶的支持数和代表样例。若某个桶很大,先补数据或改任务说明;若只是少数异常,记录已知限制可能比增加模型层数更诚实。

CLASS LAB 7.6.4比较基线与 Embedding 分类器

在固定 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。每一层只做自己的事情,错误就能定位到数据、表示或优化。

图 7.11 · 文本分类的最短可学习链自制示意:先基线,再增加表示能力
原文text + label
→
Token/IDpadding+mask
→
Embedding pool(B,D)
→
Linear headlogits
→
loss/指标错误样例

每个箭头都应有 shape、dtype 或分母证据。

表 7.6A · 文本分类基线
基线输入信息优点看不见什么
多数类标签频率最快、下限清楚任何文本内容
规则关键词/正则可解释同义与组合语义
词袋线性词频/TF-IDF强且便宜词序和上下文
平均 EmbeddingToken 向量汇聚端到端可学习复杂语序
上下文模型序列交互表达力强资源与解释成本
PYTHON · text_dataset.py
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 时检查类别数一致。

CLASS LAB 7.6.1建立三个分类基线

为一个二分类文本集合计算多数类基线,写一个关键词规则,再训练一个平均 Embedding 线性头。固定 split 和 seed,比较 accuracy、macro recall 和错误样例。

三种基线使用同一份验证集和标签映射;报告多数类准确率、规则覆盖率、模型指标和支持数。若模型不超过多数类,先检查数据、Token、label 和 loss,而不是立即调参。

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 验证训练循环,再换子词。

表 7.6B · 分类训练的形状契约
对象shape/dtype送入错误表现
input_ids(B,L) longEmbedding索引/设备错误
mask(B,L) 0/1池化PAD 污染
pooled(B,D) float分类头Linear 维度错
logits(B,C) floatCrossEntropyLoss误传概率
labels(B,) longloss/指标类别越界
PYTHON · average_text_classifier.py
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 阶段就应被验证。

图 7.12 · 分类训练的证据点自制示意:loss 下降不等于任务正确
batch contractshape/label
→
forwardlogits
→
loss分母/权重
→
metricsacc/宏召回
→
errorsID/原文

每个证据点都可以单独做测试或日志。

学习率和损失的观察

训练 loss 快速下降而验证 loss 上升,可能是过拟合、切分泄漏或验证 transform 不一致;训练和验证都不动,可能是标签、mask、学习率或参数未注册;loss 变成 NaN,先回到输入和梯度的有限性检查。记录第一批 logits、第一步梯度和每个 epoch 的有效样本数,通常比只看最后一轮更有用。

优化器应在创建模型后一次性接收参数。若冻结 Embedding,确认 requires_grad=False 且没有把它误传给需要更新的优化器;若解冻,记录解冻 epoch 和学习率。模型、词表、标签映射和训练配置共同决定结果。

CLASS LAB 7.6.2让一个小批次过拟合

选 16 条文本,训练平均 Embedding 分类器,记录每轮 loss、训练准确率、Embedding 范数和分类头梯度。若无法接近 100% 训练准确率,按数据→表示→优化顺序排查。

先验证标签和 ID,再验证 masked mean 输出无 NaN、logits shape=(B,C),最后检查 optimizer 参数、学习率和 train 模式。小批次通过后才扩大数据。

7.6.3 类别与错误:不要让一个总体数字替你解释模型

类别不平衡时,模型预测多数类也可能获得不错的 accuracy。混淆矩阵、每类 precision/recall、宏平均和支持数能揭示少数类是否被忽略。加权 CrossEntropyLoss 可以提高少数类的损失权重,WeightedRandomSampler 可以改变采样分布,但二者会影响概率校准和训练噪声,必须用验证集比较。

文本分类错误通常与长度、领域、标签边界、否定、拼写和词表未知率有关。把错误样本保存为结构化 JSON,字段包括 ID、原文、真实标签、预测标签、概率、Token 数、模型/词表版本和错误桶。人工复盘时先看高置信度错误,再看每类最常见混淆,往往能发现标签定义或清洗规则的问题。

阈值只适用于需要选择接受/拒绝边界的场景。二分类 logits 可以在验证集上选择阈值,报告 precision-recall 曲线和无结果率;多分类通常保留最大概率和置信度,但不要把未经校准的 Softmax 当作可靠概率。测试集不参与阈值搜索,发布后要监控输入长度、类别分布和置信度漂移。

表 7.6C · 分类错误桶
错误桶观察字段可能原因下一步
少数类漏检真实类/支持数不平衡、阈值加权/采样对照
高置信度错概率/Token分布外、标签噪声校准/补数据
短文本错长度/字符上下文不足保留元数据
领域集中错来源/主题覆盖不足分组评估
否定句错原文/Token汇聚丢顺序上下文模型
PYTHON · classification_report.py
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,检查词表或领域数据;如果短文本缺少上下文,比较拼接标题、字符级表示或上下文模型;如果高置信度错误来自模板,检查重复和泄漏。每个假设只改一个因素,保留旧模型和旧报告。

改进后的指标必须和旧版本使用同一验证集、同一类别映射和同一分母。若新增数据或改变切分,报告应明确这是新实验,不要把数字直接放在旧曲线旁边当作同一条件下的提升。

CLASS LAB 7.6.3复盘一个少数类错误桶

从分类结果中挑一个召回最低的类别,列出支持数、最常见混淆、三条原文和一个只改变采样或损失权重的实验方案。

需要写明真实/预测方向、分母、模型/词表版本和验证集条件。实验只改变一个变量,并同时报告少数类召回、宏平均、总体准确率和资源变化。
表 7.6D · 本节完成标准
能力最低证据下一步
搭建分类数据能返回 ID、input_ids、mask 和 label训练 baseline
对齐 loss能解释 logits、long 标签和 reduction加入指标
做小批次验证能过拟合 16~32 条样本扩大训练
分析类别能读混淆矩阵、宏召回和错误桶语义检索

07 · SEMANTIC RETRIEVAL

语义检索:从文档切块到可解释的 Top-k

语义检索把查询和文档映射到同一个向量空间,再按相似度返回候选。本节从文档切块、元数据和向量索引开始,说明召回、重排、权限过滤与结果解释,完成一个不依赖黑盒的最小检索闭环。

两阶段检索要分别回答两个问题

第一阶段问“相关内容有没有进入候选池”,第二阶段问“候选中谁应该排在前面”。如果第一阶段就漏掉了相关 chunk,重排模型再强也无法补回;如果候选池很大但排序差,用户会看到噪声。分别记录 candidate_recall、rerank_mrr、候选数量和每阶段耗时,才能知道瓶颈是召回还是排序。

查询和文档的编码模板要成对设计。查询可以加入“问题:”,文档可以加入标题和段落边界;模板改变会改变向量分布,必须进入模型/预处理版本。对技术资料,标题、API 名称和版本号常常值得额外权重,但权重应在验证集上调,而不是凭一条成功案例固定。

表 7.7F · 检索日志的最小字段
阶段字段解释用途
查询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回到证据
PYTHON · retrieval_trace.py
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 要默认不可见,而不是默认公开。

表 7.7E · 索引工程的四种一致性
一致性校验内容出错后表现
ID 一致向量行与 chunk_id 一一对应分数正确、正文错误
版本一致模型、词表、清洗和索引相互匹配同查询结果不可复现
权限一致ACL 与当前用户策略一致越权或空结果
排序一致分数、tie-break、去重规则固定刷新后顺序变化
证据一致offset 能回到同一原文高亮错位、无法核验
PYTHON · index_manifest.py
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,避免把后处理的漂亮页面误当成召回能力。对标题和正文混合打分时,记录每个信号,方便解释重排。

RETRIEVAL LAB 7.7.4审计一个索引 manifest

为 12 个 chunk 建立向量和 metadata,故意让一个 chunk_id 重复、一个向量维度错误、一个版本字段为空。编写加载检查并说明每个错误会在页面上造成什么后果。

加载器应在检索前失败,并指出具体 ID/行号;不能等用户看到错误正文才发现。检查至少包含数量、ID 唯一、维度、版本、offset 和 ACL。

7.7.1 文档切块与索引:先保证候选内容完整且可回溯

检索系统的基本单位不是“一个文件”,而是可被模型编码、可被人阅读、可被权限控制的文档块。一个 chunk 通常包含 chunk_id、document_id、原文、标题、起止偏移、来源、时间、权限标签和切块版本。只有向量而没有这些字段,检索命中后无法展示证据,也无法判断是否重复或有权访问。

切块策略要平衡语义完整性和长度预算。按段落切分通常保留自然边界,按句子切分更细,固定 Token 窗口容易控制成本,带 overlap 的滑动窗口可以减少边界截断。标题、章节名和文档路径可以作为元数据前缀加入编码文本,但展示时要区分元数据与正文,避免答案引用了导航文字。

建立索引前先做文档快照。记录清洗版本、Tokenizer、Embedding 模型、维度、归一化、chunk_size、overlap 和生成时间;为每个 chunk 计算内容哈希,重复索引时可以跳过未变化的块。文档删除、权限变化或模型升级时,索引要能增量更新或整体重建,不能依赖一个永远增长的二进制文件。

图 7.13 · 文档进入向量索引的路径自制示意:向量和元数据必须一起保存
原始文档来源/权限
→
清洗切块ID/offset
→
Embedding向量版本
→
索引向量+元数据
→
可回溯结果原文证据

检索命中只是候选,证据链还需要原文和版本。

表 7.7A · Chunk 元数据
字段作用示例缺失风险
chunk_id唯一块身份doc-01#03无法引用/去重
document_id回到原文件doc-01权限和更新困难
offset原文起止位置120-388无法高亮
text_hash内容指纹sha256...重复索引
source_version来源版本kb-2026-08结果不可复现
acl可见范围team-a越权返回
PYTHON · chunk_documents.py
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 的分母和候选集合都不清楚。

RETRIEVAL LAB 7.7.1为一篇文档设计 chunk

选择一篇 500~1000 字的技术说明,分别用段落、固定窗口和带 overlap 的窗口切分。为每个 chunk 保存 ID、offset、长度和标题,比较哪种策略最容易回到原文。

要记录 chunk_size、overlap、tokenizer/版本和原文 ID。比较不只看块数,还要看句子是否被截断、标题是否随块保留、重复比例和人工检索命中。

7.7.2 Top-k 检索与重排:先召回,再判断相关性

向量检索通常分两阶段。第一阶段用便宜的点积或余弦相似度,从全部候选中召回较大的 k;第二阶段对候选做更精细的判断,例如交叉编码器、关键词匹配、字段权重、时间衰减或业务规则。粗排 k 太小会漏掉正确答案,太大则增加重排成本。先用小数据测量 Recall@k,再决定 k 的预算。

查询和文档可能需要不同的模板或编码方式。查询可以保留问题意图,文档可以加标题和来源;如果模型训练时使用了 query/document 前缀,推理时必须保持一致。查询端和文档端的维度、归一化、dtype 和模型版本必须匹配,任何一项不一致都应在索引加载时失败。

重排分数不一定与向量分数同尺度。合并时可以做标准化、学习一个线性权重或采用词典序规则,但要用验证集选择,不能凭一条案例手调。结果列表应保留粗排分数、重排分数、过滤原因和最终 rank,帮助复盘“为什么这篇文档从第 1 名掉到第 6 名”。

表 7.7B · 两阶段检索
阶段输入规模典型方法主要目标
候选召回全库余弦/点积/ANN高召回、低延迟
去重过滤Top-k 候选document_id/权限合规与多样性
精排几十条交叉编码器/规则更准确的相关性
阈值决策精排结果验证集阈值允许无结果
展示最终少量证据片段/高亮可读、可追溯
PYTHON · rerank_candidates.py
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 和候选为空。

图 7.14 · 从候选到最终结果自制示意:每一次减少候选都要说明理由
全库N 个块
→
向量 Top-k高召回
→
去重/权限合规
→
重排相关性
→
展示证据

过滤后少于 k 条是正常的,不能用不合规结果补足。

延迟账本与质量账本要同时记录

一次检索至少记录查询 Token 数、Embedding 时间、索引搜索时间、重排时间、过滤数量、最终返回数和总 wall-clock。质量侧记录 Recall@k、MRR、无结果率和人工相关率。只优化延迟可能让召回下降,只优化指标可能让成本失控;两本账放在同一份 run 记录里,才能做真实取舍。

候选索引可以分片或分块计算。分块搜索时每块先取局部 Top-k,再合并成全局 Top-k;不能简单拼接局部结果。合并时保留稳定 tie-breaker,使用相同 dtype 和归一化,否则边界分数可能出现不一致。

RETRIEVAL LAB 7.7.2设计两阶段排序

为十篇文档和三条查询设计召回 k、去重规则、重排分数和阈值。记录每一阶段的候选数、耗时、命中情况和过滤原因。

要说明 k 如何选择、阈值由哪份验证集得到、重排分数如何归一化以及权限过滤发生在哪一层。结果中同时保留 semantic_score、rerank_score 和最终 rank。

7.7.3 检索结果可解释:把“相似”翻译成人能核验的证据

一个检索系统的用户体验不只由排序决定,还由结果能否被核验决定。返回结果应包括文档标题、来源、chunk 原文、匹配分数、rank、更新时间和必要的权限提示;如果支持高亮,保存 Token 或字符 offset。不要只返回一串文档 ID,也不要把模型生成的总结当成原始证据。

解释相似度时要区分三种说法:模型空间认为接近、规则层认为包含关键词、人工标注认为相关。它们可能一致,也可能冲突。结果卡片可以展示向量分数与关键词命中,但结论仍应以任务标注和人工复核为准。对没有相关结果的查询,说明“候选不足”“被权限过滤”还是“低于阈值”,比静默返回空白更有帮助。

权限过滤必须在展示前完成,最好在候选查询阶段就缩小可见集合。文档更新或撤销权限后,缓存的检索结果要失效;否则用户可能看到已不应访问的片段。记录查询主体、索引版本和过滤规则,才能审计一次结果是否合规。学习项目可以用简单的 acl 标签模拟这一流程。

表 7.7C · 结果解释字段
字段展示/审计用途来源注意
title/source用户定位文档元数据不能来自模型幻觉
text/offset阅读与高亮原文快照切块版本一致
semantic_score表示相似度向量模型说明归一化
keyword_hits规则证据查询/文档不是语义证明
filter_reason解释缺结果权限/阈值避免静默丢弃
index_version复现与回滚manifest与模型绑定
PYTHON · explain_result.py
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 模型和分数,关键词或重排信号,用户可见范围,最终展示片段。出现错误时沿六格回放,能够快速判断是向量不相似、规则过滤、权限变化还是展示映射错。

解释文本时避免使用“模型认为这段一定正确”。相似度只是排序信号,不能替代事实核验。对知识库问答,后续生成模型也应引用检索到的原文并标记不确定;本章先把检索证据链做好。

RETRIEVAL LAB 7.7.3制作三张结果证据卡

选择一条成功、一条相似但错误、一条因权限被过滤的检索结果,分别填写查询、chunk、分数、关键词、权限、原文和索引版本。

三张卡都应能从 ID 回到原文和索引 manifest;权限错误不应泄露正文,低分无结果要说明阈值。相似但错误的卡片要给出下一步数据或模型假设。
表 7.7D · 本节完成标准
能力最低证据下一步
切块建索引能保存 chunk ID、offset、版本和权限批量召回
两阶段排序能解释召回、重排、阈值和去重评估指标
处理合规能过滤不可见文档并说明原因发布检索
展示证据结果能回到原文而非只有分数端到端项目

08 · EVALUATION & ERROR ANALYSIS

评估与错误分析:让“效果好”有可以复核的证据

文本分类和语义检索都不能只看一个漂亮的准确率。本节把指标定义、数据泄漏、切片分析、回归测试和报告格式连起来,让一次实验可以被别人复现,也能解释下一步为什么要改数据、分词器或模型。

把每个数字绑定到一个可复算的问题

评估前先写“如果系统真的有用,用户会看到什么变化”。分类可能是少数类漏报下降,检索可能是第一条证据更常包含答案;这些目标再映射到 macro-F1、Recall@k、MRR 或人工采纳率。报告同时保留支持数和失败样例,防止一个总体均值掩盖小群体的严重问题。

标注协议要规定边界:同义表达算相关吗?只包含关键词但没有回答算相关吗?多段拼起来才能回答的问题,单个 chunk 给几级相关性?两位标注者意见不一致时如何仲裁?协议变化会改变 gold,必须记录版本并重新解释旧指标。宁可承认一部分样例“待复核”,也不要用模糊标签制造虚假的确定性。

表 7.8E · 指标报告的可复算字段
字段示例作用
metric_versionranking-v2固定计算定义
supportqueries=40判断样本量
parametersk=5、threshold=0.72解释决策点
sliceshort_text、new_source定位分布差异
toleranceabsolute=0.01定义回归边界
evidencegold-v1、error_ids回到原始案例

回归测试应分成快速 smoke、完整离线评估和人工复核三层。Smoke 只跑几十个固定样例,检查接口、形状、ID、空值和权限;完整评估计算全部指标和切片;人工复核查看最重要的升降与新出现的错误。三层速度不同,但都引用同一份版本清单,避免快速测试与正式评估使用不同规则。

对于随机模型或小数据,保存多个 seed 的结果;对于检索,保存每条查询的排名而不只是平均 Recall。遇到指标变化时,可用每条样例的配对差异查看是少数案例推动,还是普遍改善。统计显著性不是唯一标准,业务风险、延迟和证据质量同样重要。

PYTHON · regression_gate.py
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))

门禁规则只负责发现变化,不负责替你决定是否发布。报告里把“硬失败”和“需要人工确认”分开:核心少数类召回大幅下降可能硬失败;某个小切片有轻微波动则进入人工复核。每次豁免也要写原因和到期时间,避免告警永久被忽略。

EVAL LAB 7.8.4做一次指标配对分析

取同一份 gold 上的两个版本结果,列出每条查询的排名变化,找出三条改善和三条退化,并判断总体指标是否掩盖了哪一种错误。

需要把查询 ID、相关 ID、旧 rank、新 rank 和分数一起列出;若指标变化来自一两个异常样例,应在报告中注明支持数和人工复核结论。

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。

图 7.8 · 从预测结果到可解释报告自制示意:指标必须带上下文
gold labels标注协议
→
predictions模型输出
→
confusion / rank错误位置
→
slices人群/长度/来源
→
decision下一步动作

一条没有数据版本、样本量和切片定义的指标,不能独立证明模型进步。

表 7.8A · 常用指标的提问方式
指标它回答什么适合场景容易误读的地方
Accuracy全部样本中预测对多少类别均衡、错误代价接近被多数类“冲高”
Precision判为正的有多少是真的误报成本高忽略漏掉的正例
Recall真实正例找回多少漏报成本高可能牺牲大量精确率
Macro-F1每类 F1 的等权平均关注少数类公平性小类样本太少时方差大
Recall@k前 k 条是否覆盖相关项候选召回阶段不关心相关项排第几
MRR第一个相关项有多靠前单一最佳答案搜索忽略第二、第三个相关项
nDCG@k按等级衡量排序质量相关性有多级标注需要稳定的 gain 定义
PYTHON · metrics_contract.py
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、样本数、模型版本、预处理版本和运行时间。对于检索,另外记录候选池大小、去重规则、过滤规则和是否经过重排。只有上下文相同的数字才适合横向比较。

EVAL LAB 7.8.1为一个指标补齐合同

选择“分类 macro-F1”或“检索 Recall@5”,写出样本单位、标签/相关性定义、分母、空值策略、统计 split 和一次失败时要看的切片。

答案应能让另一位同学只看合同就实现同一个函数;至少出现数据版本、样本数、阈值或 k、缺标注处理和结果解释,不能只写“越高越好”。

7.8.2 数据泄漏与偏差:先证明测试集真的代表未来

数据泄漏是评估中最危险的“隐形加分”。最直观的泄漏是同一段文本同时出现在训练和测试;更隐蔽的情况包括同一用户的模板化工单跨 split、同一篇文章的相邻段落被拆开、先用全量语料拟合词表或标准化统计量、用测试查询参与负例采样,以及用未来时间的信息构造当前特征。模型可能确实学会了重复模式,但这不等于能处理新数据。

切分策略要符合部署时的数据到达方式。随机切分适合样本相互独立的静态数据;按用户或文档分组切分,适合防止同源文本泄漏;按时间切分,适合模拟“用过去预测未来”;跨域切分则用来测试换业务、换语言或换写作风格后的稳定性。没有一种切分永远正确,关键是把假设写出来,并用另一种更严格的切分做压力测试。

偏差不只是标签比例不同。长度、语言、主题、来源、设备、时间、版权状态和人工编辑方式都可能让模型偏向某些切片。一个总体 F1 可能掩盖长文本很好、短文本很差,中文很好、夹英文很差,旧文档很好、新文档很差。切片不是为了找一个“最好看的群体”,而是为了找出用户会实际遇到的盲区,并决定是否补数据或改变产品边界。

近似重复需要比字符串相等更谨慎的检测。可以先做 NFC、空白规范化和小写折叠后的哈希,再对疑似重复使用 n-gram、MinHash 或向量相似度检查。检测算法本身也要保存版本和阈值;阈值太低会把正常相似的模板误判为泄漏,阈值太高又会漏掉轻微改写。抽样人工复核是必要的,自动审计只负责缩小范围。

表 7.8B · 泄漏与偏差排查顺序
检查层示例问题证据修复动作
记录级ID、哈希是否跨 split交集清单重新分组切分
来源级同文档/用户是否拆散group overlap按来源锁定 split
时间级训练是否看到未来时间分布图时间窗重建
处理级词表、均值是否用全量拟合fit 日志只在 train fit
切片级长度、语言、主题是否失衡slice report补样本/改边界
标注级测试答案是否泄露进提示人工抽查隔离 gold 信息
PYTHON · leakage_audit.py
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 可以是用户、文档、项目或会话,应该由任务风险决定。审计结果要进入实验产物;如果发现泄漏后只在聊天里口头提醒,下一次运行很容易再次引入。

图 7.9 · 一次“高分”调查路线自制示意:先查数据,再查代码
分数异常升高与基线差距大
→
查 ID/哈希重复?
→
查 group/time同源?
→
查 fit 范围全量?
→
压力切分是否仍稳

高分需要解释;异常高分首先是审计信号,而不是庆祝理由。

偏差报告不要变成泛泛的道德口号

好的偏差记录会写出“哪一组、多少样本、哪一个指标、与总体差多少、可能原因、下一步验证”。例如:“短文本(Token≤8)占测试集 18%,macro-F1 比总体低 0.14;错误多为上下文不足,下一轮增加短标题样本并测试字符级基线。”这样的句子既承认风险,也让修复可以被验证。

如果某一切片样本太少,不要给出过度精确的排名。可以报告支持数、置信区间和“暂不下结论”,并把它列入数据收集计划。谨慎不是降低标准,而是避免把噪声包装成事实。

EVAL LAB 7.8.2设计一组压力切分

假设你的文本分类器将用于持续到来的客服工单,设计随机、按用户、按时间三种切分,并预测每种切分会暴露哪类问题。

随机切分主要检查样本级泛化;按用户切分检查新用户;按时间切分模拟未来漂移。答案应说明三者不是互相替代,而是从不同角度测试部署假设。

7.8.3 回归测试与报告:让每次改动都有可比较的记录

当你修改清洗规则、词表、Embedding 模型或检索阈值时,最怕“感觉变好了”却无法指出变化来自哪里。回归测试把一小组固定样例和指标保存下来,每次运行都自动比较。它不要求样例覆盖所有世界,而是要求最容易坏、最重要和最容易被忽略的行为有明确断言:中文与英文混排、空文本、超长文本、同义但不同写法、无结果查询、权限过滤、重复结果和模型加载失败。

金标准集合(golden set)要有稳定 ID、输入、期望标签或相关文档、难度、来源和备注。检索样例最好同时保存“必须出现的证据”和“不能出现的干扰项”,这样可以检查召回和排序。对开放文本,不要把答案写成一个唯一字符串;可以保存多个可接受标签、相关性等级或人工评分。任何修改 gold 的操作都要走审阅,否则团队可以通过改答案让指标“回归成功”。

报告至少分成四层:运行元数据、数据统计、核心指标、错误样例。运行元数据包括代码版本、依赖版本、Python/PyTorch 版本、设备、seed、词表和索引版本;数据统计包括 split、长度分布、类别支持数和过滤数量;核心指标按任务合同列出;错误样例带原文、预测、期望、分数、规则和下一步。把这些内容写成 JSON 与可读 Markdown 两份,既方便机器比较,也方便人阅读。

阈值回归与排序回归要分开。分类模型可能在阈值 0.5 下 F1 下降,但在业务允许的阈值区间仍然可接受;检索模型可能 Recall@10 不变,却把第一条证据排得更靠前。定义容差时注明“绝对差”还是“相对差”,并为关键指标设置硬门槛,为探索性指标设置告警而不是一律失败。

表 7.8C · 实验报告最小字段
区块字段为什么需要缺失后果
运行run_id、时间、commit、seed、设备复现和定位无法确认是否同一实验
输入data_version、preprocess_version、split固定数据口径数字不可比较
表示tokenizer、vocab、embedding/index version解释输入变化误把表示变化归因模型
指标名称、值、支持数、阈值/k避免分母歧义不能复算
错误样例 ID、错误类型、下一步连接分析与行动只剩一个总分
PYTHON · evaluation_report.py
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、权限过滤或样例标签变化。

一份成熟的报告会保留“未解决问题”栏目。比如某类长文档仍会被固定窗口截断,当前只通过提高重叠缓解,尚未验证新的分块策略。把未知写出来,后续的人就不会把临时补丁误当成最终方案。

EVAL LAB 7.8.3写一个最小回归清单

为本章的文本分类或检索项目列出 8 个固定样例,至少覆盖空文本、超长文本、近似重复、无结果、边界分数和一个正常成功案例,并为每个样例写断言。

每个样例应有稳定 ID;断言不仅检查“有输出”,还应检查标签、相关文档、排序、过滤理由或错误类型。任何断言变化都要注明为什么更新 gold。
表 7.8D · 本节完成标准
能力最低证据下一步
选择指标能写清分子、分母、k/阈值和空值建立 gold set
识别泄漏能按 ID、来源、时间和处理范围审计压力切分
分析错误能按切片给出支持数和下一步回归样例
输出报告JSON 可复读,Markdown 可阅读端到端交付

09 · CAPSTONE PROJECT

综合项目:做一个可追溯的中文技术问答检索器

最后一节把本章的概念串成一个小而完整的系统:读取几篇公开技术文档,清洗并切块,建立词表和向量表示,召回候选、重排结果、展示证据,再用固定样例评估。项目不追求“像产品一样复杂”,而追求每一步都能解释、运行和复盘。

把综合项目拆成三个可独立验收的里程碑

里程碑一是“文本可追溯”:输入公开文件,产出 documents 和 chunks,任意 chunk 都能通过文档 ID 与 offset 回到原文。里程碑二是“结果可解释”:输入查询,返回稳定排序、来源、分数、过滤理由和无结果原因。里程碑三是“结论可复核”:固定 gold,生成指标、切片、错误和版本报告。每个里程碑都先用标准库基线完成,再接入更复杂的模型。

验收时做一次故障演练:修改清洗版本、删除一个索引文件、把一个用户 ACL 改掉、输入空查询、加入重复文档。系统应明确失败或给出降级,不应继续输出看似正常但无法追溯的结果。故障演练比只测试成功路径更能证明项目真的掌握了数据契约。

表 7.9F · 里程碑验收卡
里程碑输入输出验收问题
文本可追溯公开文档documents/chunks/manifest能否回到字符 offset
结果可解释查询与 ACLranked evidence cards能否说明为何出现/过滤
结论可复核gold 与配置report、errors、cost改动后能否比较
故障可控空值、损坏、版本错配明确错误或安全降级是否避免静默错误
PYTHON · project_manifest.py
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;若结果变化,才能把原因聚焦到上下文表示、注意力或微调策略。

CAPSTONE LAB 7.9.5进行一次故障演练

从四种故障中选择三种:删除索引、版本错配、权限收紧、空查询。记录系统预期输出、实际输出、发现位置和修复后的回归样例。

故障不能只靠 try/except 吞掉;应有稳定错误类型、不会泄露正文,并在修复后加入固定测试。版本错配最好在加载阶段拒绝。

把作品写成一份可交接的项目档案

项目目录可以采用 data/raw、data/processed、artifacts、src、tests 和 reports 的分层。原始文件只读,处理结果带版本,模型和索引由 manifest 指向,报告按 run_id 保存。目录清晰不是形式主义:当你换一台电脑、重建词表或回看一个错误查询时,能迅速找到该找的文件。

README 开头写一个五分钟成功路径,再写完整命令和限制。成功路径应能从少量公开样例生成一个结果;完整路径再加入批处理、评估和消融。每个命令注明输入、输出和预计耗时,遇到失败给出常见原因。不要把“在我的 notebook 里运行过”当作安装说明,隐藏状态会让别人无法复现。

交付验收可以按功能、质量、可解释性和安全四个维度评分。功能看能否读取、切块、编码、检索和返回结果;质量看 gold 指标与回归;可解释性看结果能否回到来源和 offset;安全看 ACL、日志和删除。代码行数只是一种成本指标,不是学习成果。职责清楚、数据契约稳定、失败边界诚实,比堆更多模型层更重要。

表 7.9E · 项目档案的推荐目录
目录/文件内容提交前检查
README.md目标、环境、命令、限制新环境能照做
config/长度、阈值、模型、路径无本机绝对路径
src/读取、处理、表示、检索、评估函数职责单一
tests/往返、形状、边界、权限失败信息具体
artifacts/词表、索引、manifest、gold哈希和版本齐全
reports/指标、切片、错误和成本JSON/Markdown 一致
PYTHON · smoke_test.py
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 和回归样例,才能知道提升来自上下文表示还是其他改动。项目因此成为贯穿后续章节的实验基座。

CAPSTONE LAB 7.9.4写出你的项目 README 开头

用 200~300 字说明项目目标、数据范围、一次成功命令、输出字段、已知限制和下一步实验。让没有看过代码的同学也能判断项目是否符合任务边界。

README 应明确“不做什么”:例如不保证事实正确、不处理未授权文档、不支持超长附件。成功命令、输入输出和版本字段要能与实际文件对应。

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 函数替换为向量模型,并保留相同的索引和评估接口,才能做公平对照。

图 7.10 · 综合项目的数据流自制示意:每个箭头都产出可检查文件
Markdown原文与来源
→
normalize/chunkchunks.jsonl
→
encode/indexindex manifest
→
retrieve/rerankranked results
→
evidence/report可复核输出

不要只保存最终页面;中间产物是定位问题和复现实验的路线图。

表 7.9A · 综合项目的最小文件契约
文件必须包含由谁生成检查方式
documents.jsonl文档 ID、标题、来源、原文哈希读取器ID 唯一、编码可读
chunks.jsonlchunk ID、offset、正文、处理版本切块器可回到原文
vocab.jsontoken→ID、特殊 ID、版本词表构建器encode/decode 往返
index.json向量/稀疏表示、chunk ID、模型版本索引器维度和数量一致
gold.json查询、相关 ID、难度、备注人工标注抽样复核
report.json指标、切片、错误、运行元数据评估器JSON 可再次读取
PYTHON · build_text_search.py
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、索引。第四张是错误预算:允许多少空结果、重复结果、权限误展示和延迟。写完这四张纸,项目范围会比“做一个搜索器”清楚得多。

学习项目可以只用本地公开资料,不要直接把私人聊天、公司内部文档或含个人信息的文件放进练习目录。展示结果时保留出处和许可说明;自制图示可以解释架构,但不要复制受版权保护的整页截图。

CAPSTONE LAB 7.9.1搭建最小索引

准备至少 6 个短文档和 10 条查询,为每个查询标注 1~3 个相关 chunk。完成读取、切块、ID、基线排序和证据卡输出,并写一条“无结果”查询。

运行后应能从 query ID 回到原文、chunk offset 和 source;同一文档重复运行 ID 不变;无结果查询应返回明确原因,而不是随机展示一段内容。

7.9.2 消融、缓存与版本:知道哪一个改动带来了收益

端到端跑通后,不要一次替换五个组件。消融实验只改变一个因素,其余保持固定:字符切分与子词切分、无重叠与有重叠的分块、TF-IDF 与平均 Embedding、不同 pooling、不同 Top-k、不同重排权重。每个实验记录配置和结果,哪怕结果没有提升也要保留。失败的实验能告诉你当前数据和任务还不支持某个复杂方案。

缓存是为了节省重复计算,不是为了隐藏状态。一个向量缓存键至少包含规范化文本哈希、Embedding 模型版本、维度、设备/精度策略和预处理版本;索引缓存还要加入 chunk manifest 哈希和权限策略版本。只要其中一项改变,就应生成新键或明确迁移。缓存命中率高但结果版本过期,是比“没有缓存”更危险的错误。

版本清单要能回答三个问题:这个结果是用哪份文本算的?文本经过了什么处理?模型和索引是否可以重新加载?对于小项目,可以用一个 JSON manifest;对于更大项目,再引入数据版本工具和对象存储。不要一开始就把精力放在复杂平台上,先确保每一个文件都有稳定的父子关系和哈希。

比较实验时同时观察质量和成本。一个模型 Recall@5 提升 0.03,却让索引构建时间增加 20 倍、内存增加 10 倍,是否值得要看产品目标。把延迟、内存、索引大小、每次查询的向量计算次数和空结果率也放进报告。工程判断不是“最高分获胜”,而是找到满足约束的方案。

表 7.9B · 一次消融实验应固定什么
维度可变项必须固定观察结果
文本处理NFC、大小写、空白gold、模型、阈值召回/错误类型
切分段落、窗口、重叠文档版本、权限证据完整性、长度
分词字符、词、子词Embedding 维度、seedToken 数、OOV、质量
表示平均、max、首 Tokenchunk、查询、Top-kRecall@k、延迟
排序相似度、关键词混合权重候选池、过滤规则MRR、首条证据
缓存开启、失效策略输入和版本命中率、陈旧率
PYTHON · ablation_plan.py
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 即可;不要因为一个配置报错就覆盖已经完成的报告。

图 7.11 · 可复现缓存键自制示意:输入变化必须传播到键
text hash规范化正文
+
tokenizer词表版本
+
modelEmbedding 版本
+
policy过滤规则
=
cache key可失效

只用文件名做缓存键,会把同名不同内容的结果混在一起。

三种常见的“假提升”

第一,测试查询被加入了词表或索引,导致 OOV 减少但未来并没有这份信息。第二,调参多次后只报告最好的一次,忽略了选择过程本身使用了测试集。第三,缓存没有包含预处理版本,实际上读取的是旧向量。排查时把原始 manifest、运行日志和缓存命中信息一起看,通常比重新训练更快找到原因。

消融表最好同时有“配置、核心指标、支持数、成本、观察、决定”。“不显著”也是结论:它可能说明数据太小、组件不适合任务,或需要更合理的标注,而不是说明实验白做了。

CAPSTONE LAB 7.9.2做四组单变量消融

固定数据和 gold,只分别改变切分单位、pooling、Top-k、重排权重四项中的任意四组,填写质量、延迟和内存;写出一项没有提升的结论。

每组只能改变一个因素,run_id 能指回完整配置;报告中同时列成本和支持数。没有提升的实验要说明观察到的错误,而不是删除这一行。

7.9.3 交付与下一步:把实验变成可以继续学习的作品

一个合格的学习项目不要求上线,但要像可交付物一样容易运行。README 写清环境、安装、数据放置、命令顺序、输出文件、已知限制和许可证;manifest 列出输入与产物的哈希;配置文件不要依赖本机绝对路径;代码把读取、处理、表示、检索、评估、展示拆成职责清楚的函数。别人按照 README 操作,应该能得到结构相同的报告,而不是只能看你的屏幕录制。

输出界面至少展示标题、来源、正文片段、chunk ID、相似度、关键词信号、索引版本和过滤状态。用户如果点击“为什么有这条结果”,应能看到证据,而不是一句“AI 推荐”。如果没有足够相关内容,就明确说无结果并给出改写查询或扩大范围的建议。解释不等于保证正确,页面要提示相似度和答案仍需核验。

隐私和权限在学习项目里也不能完全跳过。把公开资料和私有资料分目录,检索时先过滤用户无权看到的 chunk,再做展示;错误日志不要打印整段敏感原文;删除文档时同时删除 chunk、向量和缓存。权限字段是结果的一部分,不是 UI 做完以后才补的开关。

本章结束后,下一步是 Transformer:注意力机制会让模型不只把 Token 向量做简单平均,而是根据上下文动态组合表示。进入下一章前,你应该能回答:一个 Token 如何变成向量?mask 为什么存在?相似度为什么不能代表事实正确?检索结果如何回到原文?评估失败时先查哪里?如果这些问题还不稳,回到对应小节重新跑项目中的最小实验。

表 7.9C · 交付前检查清单
类别检查项通过标准证据文件
运行全新环境按 README 执行命令顺序明确、无隐藏路径README、requirements
数据原文、许可、哈希和版本能说明来源与处理documents.jsonl、manifest
表示词表/模型/索引可加载维度、ID、版本一致vocab、index manifest
质量gold、指标、切片、错误报告可读且可复算gold、report
安全权限、日志、删除策略无越权正文和敏感输出policy、audit log
回归固定样例与 smoke test改动后能快速发现坏行为tests、baseline
PYTHON · release_check.py
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,注意力、位置编码和预训练就不再是孤立名词,而会成为表示链路中可以被实验和评估的组件。

CAPSTONE LAB 7.9.3完成交付答辩

用 5 分钟向同学说明:数据从哪里来、怎样切块、Token 和向量如何产生、检索如何排序、哪个指标最重要、一个失败案例是什么、下一步最想验证什么。把答辩要点写进 README。

答辩不以代码行数评分,而看职责是否清楚、证据是否可回溯、限制是否诚实。至少展示一条成功、一条相似但错误和一条无结果/被过滤的案例。
表 7.9D · 第 7 章结业标准
层次你应能做到自测证据
文本解释编码、规范化、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。