RAG 召回失准,可能是向量输入被截断
作者发现 all-MiniLM-L6-v2 的 256 token 上限导致其语料中 37.6% 的分块被截断,向量未覆盖原文后半段。建议使用嵌入模型的 tokenizer 切块,预留特殊 token 空间,并在入库时断言长度。
AI 深度解读
RAG 明明存着正确答案,却检索出不相干的段落,问题可能出在生成之前:向量只记录了文本的前半截。作者排查一次 API key 轮换问答时发现,all-MiniLM-L6-v2 默认只处理 256 tokens,而自己的切块器按字符计数,37.6% 的块超过了限制。
这次排查有用的地方,是把一种很难察觉的接口错配变成了可检查的问题:切块单位、模型输入上限和答案位置,必须一起验证。作者修复后的检索结果改善明显,但数据来自单一语料库,不能直接当成通用收益。
为什么每个组件都正常,答案还是找不到
作者问的是如何轮换 staging 环境的 API key,系统却返回了轮换日志文件的内容。正确答案确实存在于运行手册索引中,只是位于目标块的后面三分之一,embedding 模型根本没看到那里。
sentence-transformers/all-MiniLM-L6-v2 随附配置中的 max_seq_length = 256,这个预算包含 [CLS] 和 [SEP]。tokenizer 使用 truncation=True,超出部分会在模型运行之前被丢弃。随后 Transformer 只处理保留下来的 token,均值池化(mean pooling)也只汇总这些 token 的向量。
存储的文本块是完整的,参与检索的向量却可能只描述它的开头。
这类问题不会靠异常暴露。model.encode() 接收 2,000-token 和 200-token 的字符串,都能返回看起来正常的 384 维向量,输出上没有「发生过截断」的标记。
向量库仍然会返回正确的近邻,只不过比较对象已经缺少了关键信息。最终 LLM 收到的 top-5 上下文没有答案,再好的生成能力也补不回索引阶段丢掉的内容。
错配来自两个计量单位。LangChain 的 RecursiveCharacterTextSplitter 默认通过 length_function=len 计算长度,数的是字符。作者原先设置 chunk_size=1500、chunk_overlap=150,看着不大,但 1,500 字符的普通英文经 WordPiece 分词后,约有 300–375 tokens,已经超过 256。包含代码、路径、YAML 和长主机名时,还会更长。
例如 kubectl rollout restart deployment/payments-api -n staging 只占一行,却会拆出不少子词。文档显示得短,不代表模型读起来短。
为什么平均长度正常,也不能说明索引安全
作者对大约 400 份内部运行手册和 README 产生的 3,120 个块做了检查,其中 1,174 个超限,占 37.6%。最大长度是 611 tokens,中位数是 231,被截掉而未参与 embedding 的 token 合计 98,447。
中位数低于上限,曾让作者连续几周以为切块没问题。真正的风险在长尾,尤其是技术文档里的代码和配置。
下面这段检查直接使用 embedding 模型的 tokenizer,统计完整文本的长度,不需要 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)}")
要看的是 add_special_tokens=True 这一行:检查必须把特殊 token 算进去,才能与模型实际输入预算对齐。这里的 tokens never embedded 统计的是各块超出上限的 token 数,不是去重后的语料损失。
原有检索评测同样掩盖了问题。文档的标题、概括和首句通常排在前面,很多答案正好位于模型能看到的范围内。作者的 120 道评测题表现尚可,直到重新标注答案在正确块里的位置,才发现有 31 道题的答案从第 256 个 token 之后开始。
这些题此前被归为模型不擅长的问题,实际却连答案所在文本都没有完整进入向量。
因此,除了总命中率,还应该按答案位置拆开统计:答案位于前 256 tokens,还是位于其后。两个分组的差异,比一个汇总分数更容易暴露截断问题。位置也只是诊断维度,不能单凭答案靠后就断定某次失败必然由截断造成。
按 240 tokens 重切,和直接调到 512 有什么区别
作者的修复是使用 embedding 模型自己的 tokenizer 切块,并预留特殊 token 的空间:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
model.tokenizer,
chunk_size=240, # 为 [CLS]、[SEP] 和余量预留空间
chunk_overlap=32,
)
chunks = splitter.split_text(doc)
关键是 from_huggingface_tokenizer(model.tokenizer, ...),计数器必须与生成向量的模型一致。不能因为应用已经用了 tiktoken,就拿 cl100k_base 的计数直接替代 WordPiece。两者处理同一段代码时,结果可能差很多。
240 也不是必须照抄的神奇数字,而是作者在 256 上限之内留下余量的选择。递归切分仍可能遇到超过预算的不可分片段,例如巨大压缩代码行或长表格行,所以切完后还要逐块重新计数,并在入库前执行 assert n <= model.max_seq_length。切块配置负责尽量合规,断言负责拦住漏网文本。
重新切块后,索引从 3,120 块增加到 4,388 块,超限块降为零。同一组 120 道题中,正确块进入 top-5 的题数从 79 增加到 101。
直接执行 model.max_seq_length = 512 呢?作者说能运行,因为 MiniLM 有 512 个位置嵌入,但能运行不代表检索更好。模型在短文本对上微调过,把约 500 tokens 压成一个向量,输入已经偏离训练时的分布;一个块同时谈密钥轮换、日志保留和告警升级,均值池化还可能稀释各个主题。
作者试过 512,结果不如正确切成 240 tokens,但没有给出这一方案的具体分数。
模型之间也不能照搬上限。原文列出的 all-mpnet-base-v2 默认是 384,许多 BGE 和 E5 变体是 512。实际使用时应读取当前模型的 model.max_seq_length,需要长块时,再考虑为长输入训练的模型。
我的判断
这篇最有价值的结论,是把输入长度当成入库约束。对于需要查技术文档、配置和运行手册的 RAG,这比一开始就怀疑生成模型更容易验证,也更接近故障发生的位置。
作者的数据支持「这套修复对他的系统有效」。但从 79 到 101 的变化,同时涉及块长度、重叠和索引块数变化,原文没有提供足够的对照实验来拆分各项贡献,也没有说明重切后正确块标注如何迁移。它是有实测支撑的工程经验,还不是通用基准。
缩短文本块也有取舍。块数增加会影响索引存储和检索后的上下文组织;切得太碎,步骤、代码和说明可能分家。原文没有报告这些成本,不能只凭 top-5 改善就确定 240 适合所有团队。
和直接调大上限相比,先消除无声截断更容易建立可靠基线。之后再比较长输入模型或更大的块,才知道收益来自哪里。
能拿来做什么
- 给现有文档 RAG 做一次离线扫描,用实际 embedding tokenizer 统计超限比例、最大长度和长尾样本,优先查看代码块、YAML、路径与长表格。
- 在入库流程中保存模型标识、
max_seq_length、切块单位和重叠配置,并在生成向量前断言长度;Node 应用若把 embedding 交给独立服务,也应明确由哪一层完成这项检查。 - 给检索评测补充答案在正确块中的 token 偏移,分别看靠前答案和靠后答案的命中情况,避免总分掩盖某类固定失败。
- 更换 embedding 模型时重新检查既有文本块。先建立没有截断的基线,再用同一组问题比较更大输入上限,或适合长文本的模型。