all-MiniLM-L6-v2 在 256 个 Token 处静默截断:我的分块有 38% 受影响

Dev.to ML 2026-11-08T09:35:30.900583

问题其实很简单:“怎么轮换 staging 环境的 API key?”答案就在我的 runbook 索引里,一字不差。可我的 RAG 流水线却返回了一段关于轮换日志文件的内容。

我打开那个本该命中的文本块,答案确实在里面——在最后三分之一处。而嵌入模型(embedding model)压根没见过那部分。

all-MiniLM-L6-v2 会在 256 个 token 处悄悄截断,而我的文本块是按字符数切分的,所以超过三分之一的文本块只把前半段拿去做了嵌入。没有警告,没有报错,日志里也没有一行记录。我拿到的向量,描述的是另一段文字,而不是我存进去的那一段。

TL;DR

all-MiniLM-L6-v2 为什么会在 256 个 token 处截断?

all-MiniLM-L6-v2 之所以在 256 个 token 处截断,是因为 sentence-transformers 给它下发的配置里写着 max_seq_length = 256,而调用 tokenizer 时又传了 truncation=True。这个模型是一个小型 BERT 结构的编码器,在短句对上做过微调,256 是当初选定的一个安全上限。两行代码就能看到:

from sentence_transformers import SentenceTransformer
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
print(model.max_seq_length)  # 256

关键词是“悄悄”。model.encode() 接收一个 2,000 token 的字符串和一个 200 token 的字符串,返回两个 384 维的向量,看上去一样合规。输出里没有任何标记告诉你“这个被截断了”。其他主流模型的上限各不相同,比如 all-mpnet-base-v2 出厂就是 384。

很多 BGE 和 E5 变体用的是 512。无论你实际跑哪个模型,都去看看 model.max_seq_length,别信博客文章(包括这篇)。超过上限的 token 到底会怎样?它们会在模型运行前被删掉。tokenizer 生成 input_ids 时,保留前 255 个左右,再加上结尾的 [SEP],剩下的直接丢掉。Transformer 从头到尾没对这些 token 算过注意力,mean pooling 也只对活下来的 token 向量求平均。所以你存下来的向量,是对前 ~250 个 token 的诚实嵌入,却是对旁边那个完整 chunk 的不诚实嵌入。查询时,检索器拿问题去比对这个残缺向量,找不到任何关于密钥轮换的内容,自然把这个 chunk 排得很低。然后你的 LLM 拿到一个不包含答案的 top-5,开始努力扮演一位信心十足的同事。这就是为什么这个故障调试起来特别烦人。每个组件的行为都正确:分割器按你要求的大小产出了 chunk;嵌入模型只嵌入了它被允许看到的部分;向量库返回了真实的最近邻;LLM 根据拿到的上下文作答。bug 藏在两个单位的衔接处:分割器数的是字符,模型数的是 token。怎么检查我的 RAG chunk 是否被截断?用嵌入模型自己的 tokenizer 把每个 chunk 都 tokenize 一遍,数一数有多少超过 max_seq_length。一分钟搞定,不需要 GPU。

tok = model.tokenizer
limit = model.max_seq_length

lengths = [len(tok(c, add_special_tokens=True)["input_ids"]) for c in chunks]
over = [n for n in lengths if n > limit]

print(f"{len(over)} of {len(chunks)} chunks over {limit} tokens")
print(f"max length: {max(lengths)}, median: {sorted(lengths)[len(lengths)//2]}")
print(f"tokens never embedded: {sum(n - limit for n in over)}")

我拿大约 400 份内部运维手册和 README 切出的 3,120 个 chunk 跑了一遍,输出是:3,120 个 chunk 中有 1,174 个超过 256 token。

all-MiniLM-L6-v2 会在 256 个 token 处静默截断:我 38% 的文本块受影响

最大长度:611,中位数:231

从未被嵌入的 token:98,447

看中位数。231 个 token 听起来很安全。这让我好几周都以为分块器没问题。问题出在长尾:只要内容里有代码块、文件路径、YAML 或超长主机名,分词结果就会很糟。kubectl rollout restart deployment/payments-api -n staging 只是一行文本,却会拆出一大堆子词 token。技术文档恰恰就是最容易超预算的那类内容。我的分块配置看起来挺合理:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500,
    chunk_overlap=150,
)

1,500 个字符的普通英文散文,用 WordPiece 分词大约落在 300 到 375 个 token 之间;代码密集的文本还会更高。两种都塞不进 256。

为什么我的检索评测没发现这个问题?因为大部分答案都靠近分块的开头。标题、首句和摘要在文档里排在最前面,正好落在 256 个 token 的窗口内。我那个 120 题的评测集得分不错,我也就没再操心。后来我回头把每个答案片段定位到它所属的标准分块里,发现 120 个答案中有 31 个是从第 256 个 token 之后才开始的。这些正是那些"模型答不好"的问题。它们本身不难,只是答案被从索引里切掉了。

搭建检索评测时,要记录答案位于分块的哪个位置。按答案位置(前 256 个 token 以内 vs 之后)拆开统计命中率,一张表就能看出这个 bug。

怎么给 sentence-transformers 模型设定分块大小?

按嵌入模型的 token 来划分块,给特殊 token 留出空间,并在入库时就强制执行这个上限。

只要把正确的分词器传给 LangChain,它就能帮你数 token:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
    model.tokenizer,
    chunk_size=240,      # 256 减去 [CLS]、[SEP] 和一点余量
    chunk_overlap=32,
)
chunks = splitter.split_text(doc)

这里有两点值得注意:

要用嵌入模型自己的分词器,而不是 tiktoken。同一段字符串,cl100k_base 数和 WordPiece 数在代码上可能差很多。

用 LLM 的分词器数 token、却用 BERT 的分词器做嵌入,这就是同一个单位错配问题,只不过换了件更体面的外衣。递归分割器同样可能切出超长的块:如果碰到一段无法再拆的文本(比如压缩成一行的巨型代码、一行超长的表格),它照样会超过 chunk_size。所以一定要加上断言检查:

for c in chunks:
    n = len(model.tokenizer(c)["input_ids"])
    assert n <= model.max_seq_length, f"chunk is {n} tokens: {c[:80]!r}"

我宁可让入库阶段直接报错崩掉,也不愿检索质量悄无声息地变差。按 240 个 token 重新切块后,我的索引从 3,120 块涨到 4,388 块,而且没有一块超限。在同样的 120 个问题上,标准答案块进入前 5 名的从 79 个提升到 101 个。这只是我机器上的一个语料库加一套评测集,算不上通用基准,但提升方向非常明确。

那直接把 max_seq_length 设成 512 行不行?

可以,而且不会报错。MiniLM 是 BERT 系模型,带 512 个位置嵌入,所以设 model.max_seq_length = 512 能正常跑。但检索效果好不好是另一回事。这个模型是在短文本对上微调的,硬让它把 500 个 token 池化成一个向量,已经偏离了它训练时见过的数据分布。而且对长块做平均池化(mean pooling)还会把不同主题搅在一起:一个同时涵盖密钥轮换、日志保留、值班升级的块,最终得到的向量对这三件事都只是含糊沾边,没有一件讲得清楚。

我在自己的评测集上试过 512,效果比正经切成 240 token 的块要差。这符合我的直觉,但依然只是我自己的数据。如果你确实需要长块,就换一个专门为长输入训练的模型,并用同样的方法检查它的 max_seq_length。别硬把句子模型拉长去用,然后指望它不出问题。

我现在每个 RAG 管道都会跑的检查清单

反方向切分则会在无声无息中破坏它们。那么,all-MiniLM-L6-v2 真的会在 256 个 token 处截断吗?是的。all-MiniLM-L6-v2 会把每个输入悄悄截断到 256 个 token(含特殊 token),只对这段前缀做向量化,不给出任何警告。如果你的文本切分器按字符数来划分块,比如 LangChain 的 RecursiveCharacterTextSplitter 默认就是这么做的,那么大量技术类分块都会超出这个上限,只从前半段生成向量——这会让 RAG 检索漏掉藏在块后半段的答案。

修复办法是:用模型自带的 tokenizer 来切分,把每块控制在 240 个 token 左右;在数据入库时校验这个上限;并按答案在块中的位置来衡量检索效果。把 max_seq_length 调到 512 也能跑,但它替代不了尺寸合适的分块,也替代不了长上下文向量模型。

作者是 Preterview(一个面试准备平台)的开发者。

问题很简单:「staging 环境的 API key 该怎么轮换?」答案就一字不差地躺在我的 runbook 索引里。可我的 RAG 流水线返回的,却是一段讲怎么轮换日志文件的内容。我打开那个本该被命中的文本块——答案确实在里面,就在最后三分之一。只是嵌入模型压根没看到那一段。all-MiniLM-L6-v2 会在 256 个 token 处静默截断,而我的文本块是按字符数切分的,于是超过三分之一的块,只拿前半段做了嵌入。没有任何警告。

查看原文