QA 前缀解析边界与超长限长切分
状态:archived 类型:bug-fix Owner:backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py
问题
QA 分块解析器存在四个影响索引质量的缺陷:
# Q: xxx/## 问题: xxx这类带问答前缀的 Markdown 标题不被识别,标题剥#前无法匹配行首 Q/A 前缀,导致此类 FAQ 文档的问答边界丢失。- 渲染后的 QA chunk 没有任何长度上限。目标 embedding 模型 bge_m3 的上下文为 4096 tokens,超长单条问答在索引时会被服务端截断或报错,且截断点不可控。
- 代码围栏只识别反引号围栏,不识别 Markdown 同样允许的波浪号围栏。答案中的
~~~块内若有形似Q:/A:或# 标题的行,会被前缀提取和标题提取误切为虚构问答对。 - 前缀提取中
A:/Answer:行在问题出现前被无条件记入答案缓冲,而flush_pair()在无活跃问题时不清空缓冲。文件前言里的孤儿答案行会被拼接到其后首个真实问答对的答案前,污染提取结果。 - 标题识别未校验 ATX 标题要求的井号后空白,会把
#include等代码行当作分节标题并截断答案;超长问答又会优先寻找任意语言的答案标记,问题正文中的异语言标记可能被误认成结构分隔符。
决策
- 行首前缀匹配前先把标题行剥为纯文本,使
# Q:/## 问题:与裸Q:/问题:走同一边界识别。 chunk_markdown输出的所有 QA chunk 统一经_split_long_qa_chunks限长:超过_QA_CHUNK_MAX_CHARS = 4000字符的 chunk 保留完整问题与前缀,只切答案;答案按段落 → 行 → 固定字符硬切逐级降级,问题本身超限或 chunk 非标准 QA 格式时整条硬切,保证任意单条不超上限。问答分隔定位紧邻已知答案前缀(\t回答:/\tAnswer:)的 tab,而非首个任意 tab,问题本身含 tab 时不被截断。按行切分时 strip 仅用于判空,重组保留每行原始缩进,避免破坏围栏代码块与嵌套列表的语义。- 4000 是面向 bge_m3 4096 token 上限的保守字符数兜底:中/英/数字混合内容按接近 1 token/字的最坏情况预留缓冲,不引入 tokenizer 依赖。
- embed 侧配套
_log_long_inputs调试日志,只记录超限输入的 index 与长度,不输出内容,避免知识库文本进入应用日志。 - 围栏状态由
_update_fence_state统一追踪,```与~~~各自成对开关,异类围栏行不关闭当前块;前缀提取与标题提取两处共用同一状态机。围栏行与块内行只作答案正文,不参与问答边界与标题识别。 - 答案前缀行只在存在活跃问题时记入答案缓冲,与普通文本行的守卫一致;问题出现前的孤儿
A:/Answer:行直接忽略。 - Markdown 标题只接受井号后为空白或行尾的合法 ATX 语法;超长问答只使用与序列化问题语言一致的答案标记定位边界,其他标记保留为问题正文。
替代方案
- 按 tokenizer 精确计数切分:引入 tokenizer 依赖并与具体模型耦合,而 qa.py 目前仅依赖标准库,且 embedding 模型由部署配置决定,parser 层无法确知远端词表。仓库现有
count_tokens是正则近似(英文单词/数字/CJK 单字),完全忽略 emoji 与其他符号,对 byte-fallback 多 token 场景反而低估,不构成更可靠的 oracle。拒绝,字符数兜底与仓库「近似计数、避免引入额外依赖」的既定取舍一致。 - 在 embedding 调用处截断超长输入:会静默丢失答案后半段且切断问答结构,检索语义受损,拒绝。
- 复用 general parser 的 token 限长:通用切分不识别 QA 结构,会把问题与答案拆进不同 chunk,破坏每条 chunk 的问答完整性,拒绝。
- 把上限提到 8000+ 字符逼近 token 上限:中文内容 token 密度高,超限时失败点转移到模型服务端,不可观察,拒绝。
- 围栏沿用统一布尔翻转(不区分围栏类型):
~~~块内的```行会提前关闭代码块,混合围栏文档重新出现误切,拒绝。
后果
- 模型可见 chunk 内容变化:超长答案被拆为多条 chunk,每条重复完整问题。检索命中任一子 chunk 都能带出问题,但单条内的答案可能不完整,由召回多条补偿。
- 重复问题占用每条子 chunk 的 embedding 容量,这是保留问答语义付出的代价。
- 4000 是启发式保守值而非精确 token 换算;更换 embedding 模型或上游 token 上限变化时需要重估该常量。
- 已知限制:字符数不严格等于 token 数。byte-fallback tokenizer 下 emoji 或罕见 Unicode 单字符可占多个 token,含大量此类字符的 chunk 即使 ≤4000 字符仍可能超 4096 token。此时 embedding API 显式报错(非 429/可重试状态码),索引任务失败可见,不会静默截断;
_log_long_inputs记录的 index/len 可辅助定位。严格闭合要求加载与部署模型一致的 tokenizer,暂不接受该依赖。 - 已知限制:行级切分点不感知围栏状态,超长单段落中的代码块可能跨 chunk(开闭围栏分离),影响命中文案的渲染完整性,不影响 embedding 向量语义;围栏感知切分的状态机复杂度与该场景的触发频率不成比例,暂不引入。
- 本记录由 PR review 补录:变更本身小而完整、已随修复生效且无待裁决替代,按规范直接写
implemented,未先建proposed。
验证
backend/test/unit/test_qa_parser.py覆盖短 chunk 透传、空白过滤、段落/行/硬切三级降级、行切分保留代码缩进、问题含 tab 或异语言答案标记时完整保留、无答案前缀 tab chunk 硬切、问题超限硬切、非标准格式硬切、max_chars<=0语义,以及chunk_markdown端到端「任意 chunk ≤ 4000 字符且保留问题」的验收主张。断言边界硬编码 4000,不引用实现常量_QA_CHUNK_MAX_CHARS,避免自我引用 oracle。- 负向验证:在同一进程内 monkeypatch 移除限长步骤后,端到端测试输入产出单条 9011 字符 chunk,
test_all_chunks_within_embedding_limit在正确原因上失败;将默认常量调大到 8000 时产出 8000 字符 chunk,端到端断言同样失败;恢复 guard 后通过。 test_all_chunks_within_embedding_limit同时断言结果发生切分,防止测试输入缩水导致端到端上限断言退化为恒真。- 同一
backend/test/unit/test_qa_parser.py还覆盖 tilde/backtick 围栏边界、带 info string 围栏、混合围栏不提前关闭、未闭合围栏吞并剩余、标题提取路径围栏、端到端chunk_markdown只产一对、首问题前/分节后的孤儿答案被忽略、活跃问题下多 A: 行仍完整归入,以及前缀和标题路径均保留#include代码行。 - 围栏负向验证:monkeypatch 回退为只识别反引号的旧状态机后,评论场景输入产出 2 对虚构问答,
test_tilde_fence_content_stays_in_answer在正确原因上失败;回退行级 strip 后缩进代码行丢失,test_line_split_preserves_code_indentation在正确原因上失败;回退首个 tab 分隔后含 tab 问题被截断为 tab 前部分,test_question_containing_tab_kept_whole在正确原因上失败;回退孤儿答案守卫后前言被拼入真实答案,test_orphan_answer_before_first_question_discarded在正确原因上失败。 - dispatcher 回归:用修复后的 qa.py 复跑
test_qa_chunking_from_markdown_headings的标题风格 FAQ 场景,产出与预期一致;test_ragflow_like_chunking.py依赖完整包环境,待容器内补跑。 - embed 日志修复(只记 index/len):AST 语法解析与
git diff --check通过。 - 待 PR 环境补跑:
docker compose exec api uv run --group test pytest test/unit/test_qa_parser.py test/unit/plugins/test_ragflow_like_chunking.py。