长文档问答开始漏前文,先别急着换模型:先用请求日志量出实际送入的 token 数,再决定是调窗口还是改分块。如果日志显示实际 token 明显低于配置的最大上下文长度,并且缺失的原文总是落在块边界附近,问题就在切块与拼接;如果 token 已经贴到上限或被截断标记命中,问题在窗口与超长截断策略。这两件事必须分开验证,同时改两项会让结果互相掩盖。
适用场景:长文档问答出现答案不完整、前文信息丢失。操作动作:先记录每次请求的 prompt token 与 chunk 边界,用同一问题在固定分块下改变偏移重复提问,定位丢失段落出现在哪一环。验证方式:缺失段落是否随块边界移动,放大窗口后是否消失。风险边界:日志只能证明“送进去了什么”,不能证明模型一定用上了;结论需要多次重复提问后再定。
在请求日志里量出实际送入的 token 数
先确认问题是不是窗口截断造成的。要点是:文档本身的 token 数不等于送入模型的 token 数,system prompt、历史对话、拼接模板文字都会额外占位。所以要在 messages 拼接完成之后、发请求之前统计一次,并在响应回来之后与服务端返回的用量字段对照。多数兼容 OpenAI 协议的接口会在 usage 里返回输入 token 数,具体字段名需要结合你的接入方式和 SDK 版本确认。
# 通用骨架:拼接后的 messages 统计,按你使用的 SDK 替换函数名
msgs = build_messages(system_prompt, retrieved_chunks, history, question)
text = "\n".join(m["role"] + ":" + m["content"] for m in msgs)
total = count_tokens(text) # 本地 tokenizer 估算,或先用一次小请求的 usage 校准
record = {
"question_id": qid,
"chunk_ids": [c.id for c in retrieved_chunks],
"chunk_tokens": [count_tokens(c.text) for c in retrieved_chunks],
"system_tokens": count_tokens(system_prompt),
"history_tokens": count_tokens(history_text),
"prompt_tokens": total,
"max_context_tokens": CFG["max_context_tokens"],
"overflow": total > CFG["max_context_tokens"],
}
logger.info(json.dumps(record, ensure_ascii=False))
# 响应回来后追加一行,与服务端计数对照
logger.info(json.dumps({"question_id": qid, "usage_prompt_tokens": resp.usage.prompt_tokens}))
记录方式建议按 question_id 逐条落盘,字段至少保留 prompt_tokens、max_context_tokens、overflow、chunk_ids。翻十到二十条历史日志,如果 overflow 为 false,窗口截断基本可以排除;如果 prompt_tokens 贴着配置上限,或者日志里出现了截断告警,就先处理超长输入,别去动分块参数。
同一份文档按固定长度切块,观察答案随块边界漂移
判定分块是否导致段落丢失,办法是让块边界移动,看缺失内容会不会跟着移动。把同一份文档按固定长度切块,切块偏移分别取 0、四分之一块、半块、四分之三块,重叠长度保持为 0,每次用完全相同的问题提问。为了能定位缺失的是原文哪一段,在每块开头写入唯一标记,比如 [S07],再在答案里检查哪些标记被引用过。
DOC = open("doc.txt", encoding="utf-8").read()
L = CFG["chunk_size"]
def split(text, size, overlap=0, offset=0):
out, start = [], offset
while start < len(text):
out.append(text[start:start + size])
start += size - overlap
return out
for offset in (0, L // 4, L // 2, 3 * L // 4):
chunks = split(DOC, L, overlap=0, offset=offset)
answer = ask(build_prompt(chunks, QUESTION))
print("offset=", offset, "缺失标记=", [m for m in all_marks(chunks) if m not in answer])
结果记录成表:每次提问用了哪些 chunk_id、答案引用了哪些标记、没被引用的是哪几段原文。如果缺失段落随偏移改变而改变,并且总是被切断在块的接缝处,就基本可以确认是分块切碎了内容;如果无论怎么偏移,缺失的都是同一段靠前的内容,那更像是窗口截断或者注意力衰减,回到第一步再量一次 token。
调整分块重叠长度,看前文召回是否恢复
确认是分块问题后,再验证重叠参数对召回的实际影响。配置骨架如下,chunk_overlap 是需要逐组改的变量,其它参数保持不变。
chunking:
strategy: fixed_length
chunk_size: 800 # 字符或 token,按你的实现口径统一
chunk_overlap: 0 # 对照组1
# chunk_overlap: 400 # 对照组2,约为半块
# chunk_overlap: 800 # 对照组3,等于整块
split_by: paragraph_first # 可选:先按段落再按长度兜底
三组各跑同一批问题,记录三件事:答案是否包含原先缺失段落里的关键句;送入的 chunk 数与总 prompt token 的变化;答案里是否出现明显的重复表述。零重叠用于确认基线丢失;半块重叠通常足以让被切断的句子两头都落在某一块里;整块重叠会让块间内容大量重复,token 成本增加明显,还可能在答案里带出重复句。经验上先试接近半块的重叠,再按实际召回情况微调,具体取值需要结合你的文档结构和窗口余量确认。
固定分块不变,只放大上下文窗口
把窗口和分块这两个变量彻底分开。同一份切块结果、同一段 prompt、同一个问题,只改窗口上限与超长截断策略,再问一次。
context:
max_context_tokens: <当前配置值> # 只改这一行,逐组放大
overflow_policy: truncate_head # truncate_head / truncate_tail / reject
keep_system_prompt: true # 截断时是否保留系统提示
对比答案是看同一问题在放大后缺失段落是否消失。如果放大窗口后答案完整度没变,说明丢失与窗口无关,回到分块与拼接环节查;如果放大后恢复完整,但你并不想为此付出更高的输入成本,那就应该改成分块加重叠,而不是长期依赖大窗口。注意 overflow_policy 的选择会直接影响结果:从头截断会丢掉最早送入的内容,从尾截断会丢掉最后送入的内容,两者看起来都像“忘掉前文”,排查时要先看清楚用的是哪一种。
把结论整理成先量后调的排查顺序
- 先量送入 token。看日志里的 prompt_tokens 与配置的 max_context_tokens 的关系,以及 overflow 标记。判定阈值来源就是这两个配置值本身,不需要经验百分比。overflow 为 true,或 prompt_tokens 已经贴近上限,先解决超长输入。
- 再看块边界。token 没超上限仍有缺失时,跑偏移对照实验。判定依据是缺失段落是否随切块偏移移动,并固定落在接缝处。移动则判定为分块丢段,进入调重叠参数;不移动则继续查拼接逻辑是否被去重、截断或排序覆盖。
- 最后调窗口。只有在偏移实验显示缺失与块边界无关、且日志显示输入确实吃紧时,才放大 max_context_tokens 并确认 overflow_policy。放大后仍需复跑同一批问题,确认缺失段落是否回归,不能只看单次回答。
每一步只动一个变量,并把改动前后的日志放在一起对比。这样即使最终需要同时调分块和窗口,也能说清楚每一处改动各自解决了哪一段丢失。