先固定输入再谈效果——GPT-6.1 Sol 长文档处理该怎么设边界

文章导读
长文档丢进 GPT-6.1 Sol 后前后不一致、中间一段像被跳过,往往不是模型读不完,而是输入没固定:文档怎么切、每块带多少上下文、按什么顺序送进去,这些没写成规则,输出质量就没有比较的基础。先做输入侧的动作——量长度分布、按标题粗切、给每块打上原文位置标记、按顺序投喂并记录——把“输入是什么”变成可核对的日志,再谈效果。下面每一步的判断都尽量落在切块数量、原文偏移量和输出对应关系这三类可验证的
📋 目录
  1. 壹 先量出文档的实际长度分布
  2. 贰 按标题层级做一次粗切,每块保留来源位置标记
  3. 叁 按顺序投喂各块并记录每块输出片段
  4. 肆 对比整篇直投与分块投喂的差异并只保留可复现结论
  5. 伍 把确定下来的输入规则写成调用前的检查清单
A A

长文档丢进 GPT-6.1 Sol 后前后不一致、中间一段像被跳过,往往不是模型读不完,而是输入没固定:文档怎么切、每块带多少上下文、按什么顺序送进去,这些没写成规则,输出质量就没有比较的基础。先做输入侧的动作——量长度分布、按标题粗切、给每块打上原文位置标记、按顺序投喂并记录——把“输入是什么”变成可核对的日志,再谈效果。下面每一步的判断都尽量落在切块数量、原文偏移量和输出对应关系这三类可验证的信息上。

先固定输入,再谈效果。适用场景是需要回溯原文的长文档问答:先把总字符数、段落数、标题层级分布和最长连续段落量出来,按标题做一次带原文偏移量的粗切,逐块按序投喂并记录每块输出,再把整篇直投与分块投喂各跑一遍,只保留能重复出现的差异。验证靠切块数与标题数是否一致、输出能否定位回原文偏移量。边界是:切太碎会切断跨段引用,切太粗等于没切,换模型或换参数后要重跑。

先量出文档的实际长度分布

分块规则要服从文档本身的结构,而不是先拍一个“每块 2000 字”。把下面这段统计脚本放在文档目录下跑一次,输出的四个数字基本决定了后面能怎么切。

# measure_doc.py —— 把 doc.md 换成实际文件路径
import re, pathlib
from collections import Counter

text = pathlib.Path('doc.md').read_text(encoding='utf-8')
paras = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
heads = [(len(m.group(1)), m.group(2).strip())
         for m in re.finditer(r'^(#{1,6})\s+(.+)$', text, re.M)]

print('总字符数', len(text))
print('段落数', len(paras))
print('标题数', len(heads))
print('标题层级分布', dict(sorted(Counter(h[0] for h in heads).items())))

longest = max(paras, key=len)
print('最长连续段落字符数', len(longest))
print('最长段落开头', longest[:60].replace('\n', ' '))

看结果时的判断:标题数与章节数对不上,说明文档里的层级标记不统一,按标题切之前要先规整;最长连续段落如果接近或超过准备设的每块上限,按段落和按标题切都会出现超限块,需要为这一块单独准备二次切分规则;标题层级只有一层的文档,按标题切出来的块会很少,粗切之后还得按段落或字符数再分一次。这些都能在统计结果里直接看到,不必先试投喂。

按标题层级做一次粗切,每块保留来源位置标记

粗切的目的不是把文档变小,而是让每一块都能回到原文的某个区间。下面这段骨架遍历所有行,在标题行前断开,每块记下标题、起止偏移量、正文和序号;偏移量直接用原文下标,方便还原和追溯。

# split_by_heading.py
import re

def split_by_heading(text):
    pos, cur, blocks = 0, None, []
    for line in text.splitlines(keepends=True):
        if re.match(r'^#{1,6}\s+', line):
            if cur is not None:
                cur['end'] = pos
                blocks.append(cur)
            cur = {'title': line.strip(), 'start': pos, 'body': ''}
        elif cur is not None:
            cur['body'] += line
        pos += len(line)
    if cur is not None:
        cur['end'] = pos
        blocks.append(cur)
    for i, b in enumerate(blocks, 1):
        b['seq'] = i
    return blocks

需要替换的只有读取路径和标题行的正则。如果文档用编号章节而不是 # 标题,改正则即可,切块逻辑不用动。

先固定输入再谈效果——GPT-6.1 Sol 长文档处理该怎么设边界

验证方式是切块数量与原文标题数一致:两者相等,说明每个标题都开了一个块。如果文档开头有前言这类不属于任何标题的内容,切块数会比标题数多一块,把它单独记为 seq=0,同样保留起止偏移量,而不是直接丢掉。再补一条还原校验:把所有块的 text[start:end] 按序拼起来,应当与原文一致。拼接不相等,通常意味着标题行、空行或前言在某一步被吞掉了,先修这里,别急着投喂。

按顺序投喂各块并记录每块输出片段

投喂时按 start 升序逐块发送,每块只带自己的正文和一句固定说明,例如“只依据本块内容回答,引用时给出块序号”。每块返回后立刻记一行,格式保持简单,方便后面用 grep 过滤。

seq=3 start=1840 end=2610 order=3 locatable=true note=
output_excerpt: ……

把结果摊到一张表里,比在聊天记录里来回翻更可靠:

块序号原文起止偏移量投喂顺序输出能否对应原文备注
00-3181能前言块,无标题
1318-10422能—
21042-18403否输出含第 4 块才有的内容,需核对
31840-26104能—

标成“否”的块分两类:一类是输出里出现了其他块才有的信息,说明上下文串了;另一类是整块没有产生任何可对应内容,可能是被忽略,也可能是这一块本身只是空标题或目录。两类都要回到起止偏移量去核对原文那一段到底是什么,再决定是重投、并入相邻块,还是记为已知缺口。中间块被吞掉的情况,通常在这一步就暴露,不必等到最后一轮对比。

先固定输入再谈效果——GPT-6.1 Sol 长文档处理该怎么设边界

对比整篇直投与分块投喂的差异并只保留可复现结论

对比的前提是控制变量:同一份文档、同一段提示词、同一组参数,用整篇直投跑一遍,再用分块投喂跑一遍。得到的差异点逐条记下来,并且至少再跑一次,看同一条差异是否再次出现。

  • 差异点一:某段内容在分块方式下出现、整篇方式下没有——记下它属于哪个块。
  • 差异点二:同一个问题两次回答互相矛盾——记下是哪两块之间矛盾。
  • 差异点三:引用位置能追溯到原文偏移量,或者完全对不上。

只出现一次的差异先标成待观察,不要直接写进规则;每次都复现的差异,才值得改分块方式或调整块间衔接。需要提醒的是,模型名、参数或上下文上限配置一变,之前的对比就不能直接沿用,要重跑。也不建议把某次表现好的配置当成通用结论,它只对这份文档和这组参数成立。

把确定下来的输入规则写成调用前的检查清单

把上面确认下来的内容写成一页清单,放在调用之前逐条核对,比事后解释输出为什么不对更省时间。

  • 切分方式:本次按标题切、按段落切还是按字符数切,写在配置里,不临场决定。
  • 每块上限:字符数上限是多少;出现超限块时用哪条规则做二次切分。
  • 顺序:按 start 升序投喂,记录里保留 seq 与 start,不依赖对话顺序的记忆。
  • 块间衔接:跨块引用的内容是否需要额外带入上一块尾部和下一块头部,各带多少字符。
  • 来源位置:每块是否都带原文起止偏移量,输出能否回溯到原文。
  • 失败处理:某块无输出或无法对应时,是重投、并入相邻块,还是标记为已知缺口。
  • 版本固定:文档版本、提示词、模型名与参数记录在同一份日志里,便于复跑对比。

这七条里,切分方式、每块上限、顺序、块间衔接四项是输入规则的核心,其余三项负责让规则能被验证和复跑。规则固定之后再比较整篇直投与分块投喂的差异,结论才有落点。