索引侧和查询编码侧各写一份最大长度时,同一句长文本会在两侧被截到不同位置:进索引的是前 N 个 token 的向量,检索时算出的是前 M 个 token 的向量,两者描述的不是同一段文字,长句排不上来就不奇怪。判断顺序可以直接照着做:先确认两侧实际生效的取值(不是脚本里写的那个),再用一条超长句把两侧向量做数值比对,然后把长度上限收敛到一个配置来源,最后用固定样本集做回归。只改查询侧的 max_length 属于临时处理,能减轻现象,但两侧仍会分叉。
建索引与查询编码的最大长度不一致时,优先怀疑三处各有一份取值:索引脚本里的参数、查询服务的编码调用、tokenizer 自带的默认长度,后加载的会覆盖前面的。先用日志确认实际生效值,再用同一超长句分别编码并计算余弦相似度;两侧截断点一致时应接近该模型自身的重复编码基线,明显偏低即说明截断位置不同。收敛到单一配置来源是根因处理。注意最大长度的单位(token 还是字符)和 SDK 参数名要以实际环境为准。
确认建索引时实际生效的最大长度
索引脚本里出现长度的地方往往不止一处,先把它们列清楚再谈对齐,否则你改的那一处可能根本没被用到。
- 模型加载或 tokenizer 初始化处:有的封装会在初始化时写入一个默认最大长度。
- encode / batch encode 调用处:max_length、truncation、padding 三个参数经常被写在批处理函数里。
- 批处理封装或公共工具函数:上层传了参,下层又用默认值兜底,实际取值由兜底逻辑决定。
- 环境变量或配置文件:容器镜像里预设的值,脚本读不到时静默回退到代码默认值。
确认方式不要靠读代码猜,直接打日志,把“配置值”和“实际送入模型的长度”一起打印出来,两者对不上就是有覆盖。
import logging
MAX_LEN = 512 # 索引脚本里声明的值
logging.info("tokenizer.model_max_length=%s", tok.model_max_length)
logging.info("脚本声明的 MAX_LEN=%s", MAX_LEN)
batch = tok(texts, truncation=True, max_length=MAX_LEN, return_tensors="pt")
logging.info("input_ids.shape=%s", tuple(batch["input_ids"].shape))
logging.info("本批最长 token 数=%d", int(batch["attention_mask"].sum(1).max()))
被覆盖的典型现象是:本批最长 token 数长期贴着某个比 MAX_LEN 小的值停住,无论你怎么调大 MAX_LEN 都不动;或者 tokenizer.model_max_length 是一个你从没写过的数。出现这两种情况,先去查封装层和环境变量,再回来讨论截断点。
确认查询编码时的截断行为
查询侧的问题常出在两个地方:一是编码调用处的 max_length 与索引侧不同;二是预处理阶段按字符做了截断,比如先把 query 切到固定字符数再送进模型,这时真正决定截断点的是字符串处理,不是模型的 token 上限。两种要分开看。
确认方法是同一个文本用不同长度上限各编码一次,看向量什么时候开始变化:如果两个上限下向量完全相同,说明截断点落在更靠前的位置,或者文本根本没被截断;如果向量不同,再用 token 数确认截断具体落在第几个 token。
def encode(text, max_len, truncate=True):
# 调用签名以实际 SDK 为准,这里只保留长度与截断两个变量
return model.encode([text], max_length=max_len, truncation=truncate, normalize=True)[0]
q = "……一条明显超过上限的长句……"
vec_full = encode(q, max_len=4096, truncate=False) # 参照组
vec_side = encode(q, max_len=SIDE_MAX_LEN) # 查询侧实际配置
vec_idx = encode(q, max_len=IDX_MAX_LEN) # 索引侧实际配置
差异要落到记录上才有用,建议每条 sample 写一行 jsonl:query 标识、两侧 max_length、两侧实际 token 数、两个向量之间的余弦相似度、执行时间。只截图日志、不留下可复算的记录,回归时就无从对照。
用一条超长句对照两侧向量是否一致
把配置差异变成数值差,是这一步唯一的目的。选一条明显超出两侧上限的句子,按两侧配置各编码一次,算余弦相似度。
import numpy as np
def cos(a, b):
a = np.asarray(a, dtype="float32")
b = np.asarray(b, dtype="float32")
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
base = cos(encode(q, SIDE_MAX_LEN), encode(q, SIDE_MAX_LEN)) # 自身重复编码基线
cross = cos(vec_idx, vec_side)
print("自身基线", round(base, 6), "两侧一致性", round(cross, 6))
判定标准以“自身基线”为参照:同模型同文本重复编码的结果应几乎一致,落在浮点误差范围内;两侧一致性明显低于自身基线,就说明两侧截断点不同,需要回到前两节去找是哪一侧的取值没生效。注意不要把某个具体阈值当成硬标准,不同模型、归一化与否、量化与否都会影响相似度的绝对水平,用自身基线做相对比较更稳。
判定结果同样写进记录,字段建议包含:样本标识、索引侧长度与 token 数、查询侧长度与 token 数、自身基线、两侧一致性、是否判定为不一致。后面回归时这份记录就是对照表。
把长度上限收敛到一个配置来源
根因是“两侧各写一份”。收敛的做法是先抽出一个配置项,再让索引脚本和查询服务都从同一处读取,调用处不再出现字面量。
# config/embed.yaml
embedding:
model: "<模型标识>"
max_length: 512
truncation: true
# common/embed_config.py —— 两侧共用同一个加载函数
import os, yaml
_cfg = None
def load_embed_config(path=None):
global _cfg
if _cfg is None:
path = path or os.environ.get("EMBED_CONFIG", "config/embed.yaml")
with open(path, "r", encoding="utf-8") as f:
_cfg = yaml.safe_load(f)["embedding"]
if os.environ.get("EMBED_MAX_LENGTH"): # 显式覆盖也要留痕
_cfg["max_length"] = int(os.environ["EMBED_MAX_LENGTH"])
return _cfg
改法要点有两条:索引脚本和查询服务都只通过 load_embed_config() 取 max_length,不再各自声明;如果允许环境变量覆盖,覆盖后必须打日志,避免又变成“查不到来源的第二个值”。改动前后各跑一次确认:改动前两侧日志打印的生效值可能不同,改动后两侧打印的应是同一个数,且这个数与配置文件一致。
回归验证同一 query 两次检索结果一致
改造后要确认行为稳定,靠的是固定样本集加可重复的脚本,而不是临时抽几句试试。
样本集建议至少覆盖:明显短于上限的短句、刚好在临界长度附近的句子、明显超长的句子、中文与混合语言各若干条,以及一条“用文档原文自身作为 query”的自检索样本。自检索样本最直观:如果两端对齐,它应当稳定排在首位。
import json
report = []
for item in samples:
qid, text = item["id"], item["text"]
v1 = encode(text, load_embed_config()["max_length"])
v2 = encode(text, load_embed_config()["max_length"])
hits_a = search(v1, top_k=10)
hits_b = search(v2, top_k=10)
report.append({
"id": qid,
"self_cos": round(cos(v1, v2), 6),
"topk_same": [h[0] for h in hits_a] == [h[0] for h in hits_b],
"top1_a": hits_a[0][0],
"top1_b": hits_b[0][0],
})
with open("regress_report.jsonl", "w", encoding="utf-8") as f:
for r in report:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
一致性判据分三层:同一文本两次编码的余弦相似度落在浮点误差范围内;同一 query 两次检索的 top-k 标识顺序一致(分数并列导致的相邻交换可以接受,需要单独标注);自检索样本稳定排首位。任何一层不满足,先回到配置来源检查是否有第二处仍在写死长度,再考虑向量库或索引版本是否同步更新过。
需要固定下来的除了样本集本身,还有样本集的版本、生成向量时的模型标识和最大长度取值、以及回归报告文件。三者一起留档,下次再出现长句排不上来的问题,才有可对照的基线。