Skip to content

fix(knowledge): 分块合并使用配置的分隔符 - #1150

Merged
xerrors merged 3 commits into
xerrors:mainfrom
lianbie:fix/naive-merge-delimiter
Nov 5, 2026
Merged

xerrors merged 3 commits into
xerrors:mainfrom
lianbie:fix/naive-merge-delimiter

Conversation

@lianbie

@lianbie lianbie commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

修改内容

naive_merge 接收 delimiter 参数,但常规合并路径从不使用它:chunk_token_num <= 0 时硬编码 "\n" 拼接,预算路径每段也以 "\n" + sec 追加。

后果是默认分块器 general 忽略调用方配置的分隔符,并把分隔符本身替换为换行,chunk 内容与原文不一致。

复现

通过公开入口 dispatcher.chunk_markdown,输入 40 个空格分隔的词,配置 delimiter=" "、chunk_token_num=10:

分块器 修复前 修复后
separator 40 个 chunk,每个一个词 不变(本就正确)
general(默认) 4 个 chunk,内部空格全变为 \n 4 个 chunk,空格保留

general 的 chunk 数量由 token 预算决定(这是它的设计),本 PR 只修正分隔符被忽略与原文被改写这两点。

另一处

预算路径的输出还带一个前导分隔符:

naive_merge([("alpha", ""), ("beta", "")], chunk_token_num=512, delimiter=" ")
# 修复前 -> ['\nalpha\nbeta']

调用方 .strip() 掩盖了它,但说明该路径的拼接未经验证。naive_merge 此前没有任何单元测试覆盖。

改动

  • backend/yuxi/modules/knowledge/chunking/ragflow_like/nlp.py:入口按 separator = delimiter or "\n" 归一,两条路径共用;预算路径首片段不加分隔符。
  • backend/test/unit/knowledge/chunking/test_naive_merge_delimiter.py:4 项回归测试。
  • 决策记录:2026-11-04-naive-merge-delimiter.md。

字符合并仍经 join_source_text,source_positions 映射不变,入库行号定位不受影响。delimiter 为空时回退换行,与 _iter_sections 的默认语义一致。

验证

  • 新增 4 项用例在修复前全部失败,修复后通过。
  • 与 test_semantic_chunking.py、test_chunk_source_lines.py 合并运行:23 项通过。
  • ruff check、ruff format --check、ruff check --select I 均通过。

未覆盖

  • 未验证真实文档入库后的检索效果,本变更只保证 chunk 内容与配置一致。
  • general 的 token 预算逻辑未改动。
  • 本机为 Windows,只在隔离环境运行了相关用例;容器内全量 test/unit 由 CI 执行。

Fixes #1148

naive_merge 接收 delimiter 参数却从不使用:chunk_token_num <= 0 时硬编码 "\n"
拼接,预算路径每段也以 "\n" + sec 追加。结果是默认分块器 general 忽略调用方
配置的分隔符,并把分隔符本身替换为换行,chunk 内容与原文不一致;对空格敏感的
文本语义会被改变,向量索引随之偏移。

同一输入在 separator 分块器下行为正确,可作为对照。

按 separator = delimiter or "\n" 归一后两条路径共用:无预算路径传给
join_source_text,预算路径追加时使用它且首段不加分隔符,消除前导分隔符。
字符合并仍经 join_source_text,源位置映射不变。

Fixes xerrors#1148
@lianbie

lianbie commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Friendly bump on this one as well — also mergeable: clean.

Recap: naive_merge accepts a delimiter argument but never uses it on the merge path — chunk_token_num <= 0 hardcodes " ", and the budget path appends " " + sec. So general (the default chunker) ignores the delimiter the caller configured and rewrites the delimiter itself into a newline:

input: 'word0 word1 ... word39', delimiter=' '
separator parser -> 40 chunks (correct)
general (default) -> 4 chunks, each holding 'word0
word1
...' (delimiter replaced)

The budget path also emitted a leading separator (' alpha beta'), which callers masked with .strip() — naive_merge had no unit tests at all before this PR.

Fix: normalise separator = delimiter or " " once and use it on both paths, with no leading separator. 4 new cases fail before the change and pass after; 23 related tests pass in total.

Separator is untouched: it splits and does not merge, so it was already correct.

xerrors commented Nov 4, 2026

Copy link
Copy Markdown
Owner

方向可以保留,但实测有两处回归,先修完再合并:

  1. general 输入 alpha|beta|gamma|delta|epsilon|zeta,delimiter="|"、chunk_token_num=2,第二块变成 |delta|epsilon|zeta,新块多了前导分隔符。
  2. book / laws 共用 naive_merge,配置 | 后原有行间换行也被改写。比如 book 的 alpha\nbeta\ngamma\ndelta 变成了 alpha|beta|gamma|delta。

请区分切分分隔符和片段连接符,并在确定追加旧块还是开启新块后再拼接,保留 overlap 和自定义分隔符行为。只补这两类最小回归用例即可。

naive_merge 的 delimiter 是片段之间的连接符,切分已由调用方完成,两者不能混用。
上一版把 delimiter 默认设成配置值,导致两处回归:

- 片段自己开启新块时仍带前导分隔符(general,delimiter="|"、num=2 时第二块变成
  "|delta|epsilon|zeta");
- book/laws 的片段边界来自按行切分,行间换行属于原文结构,被 delimiter 覆盖后
  "alpha\nbeta" 变成 "alpha|beta"。

改为在 add_chunk 里区分「并入已有块」和「开启新块」后再决定是否加连接符;
delimiter 默认 None,表示片段边界来自文档结构,此时保留换行。book/laws 不再
把 delimiter 透传给 naive_merge,它们的切分与连接都按行/按句进行。

overlap 与自定义分隔符(反引号)行为未变。
@lianbie

lianbie commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

两处回归都修好了(7eb22f03),谢谢实测。

根因:naive_merge 的 delimiter 是片段之间的连接符,切分已经由调用方完成,上一版把两者当成一回事了。

回归 1(前导分隔符):原来在进循环前就拼好 separator + sec,但片段可能自己开启新块。现在把拼接移进 add_chunk,先判断是并入已有块还是开新块,只有并入时才加连接符:

if chunks[-1] and token_nums[-1] <= threshold:
    text = chunks[-1] + sep + sec if sep else chunks[-1] + sec
    ...
prev = _remove_pdf_tags(chunks[-1])
start = int(len(prev) * (100 - overlap) / 100.0)
text = prev[start:] + sec          # 新块不带前导分隔符

delimiter="|"、chunk_token_num=2 现在是 ['alpha|beta', 'gamma|delta', 'epsilon|zeta']。

回归 2(行间换行被改写):book / laws 的 sections 是 _iter_sections / _iter_law_sections 按 splitlines() 取的,片段边界来自文档结构,而不是 delimiter。把 naive_merge 的 delimiter 默认值改成 None——表示「片段边界来自文档结构,用换行连接」——并让 book/laws 不再透传 delimiter:

  • book.py:两处调用去掉 delimiter=,顺带删掉已无用途的 unescape_delimiter 导入
  • laws.py:_ensure_chunk_token_limit 的 delimiter 形参一并去掉(它只用来透传给内部按行/按句的 naive_merge)

general 仍然显式传 delimiter,行为不变:'alpha|beta'。

用例:按你说的只补这两类最小回归,共 2 条,在旧代码上均失败:

  • test_new_chunk_does_not_start_with_the_separator
  • test_line_sections_keep_their_newlines_without_a_connector

chunking 相关 26 个测试全过,overlap 与反引号自定义分隔符的行为没动。

xerrors commented Nov 4, 2026

Copy link
Copy Markdown
Owner

对照 base 953703ba 和当前 7eb22f03 实测,仍有两处本 PR 引入的回归,请修复后再合并:

  1. general 输入 alpha|beta|gamma|delta|epsilon|zeta,delimiter="|"、chunk_token_num=2、overlapped_percent=25,现在输出 etagamma、eltaepsilon,overlap 尾部和新片段粘连;base 有换行隔开,默认换行配置也受影响。尾部非空时需保留连接符。
  2. book 输入 alpha CUT beta CUT gamma,配置 delimiter 为反引号包裹的 CUT、chunk_token_num=100,base 输出三个片段,当前却保留为一整块。laws 同样丢失已有自定义切分能力。请区分自定义切分配置和行间连接符,不要直接删除 delimiter 透传。

原先的前导分隔符和普通行间换行问题已修复。只补上述两类最小回归用例即可。

上一版还有两处回归:

- overlap 截取上一块尾部时,尾部与当前片段直接拼接。overlap=25 时
  "eta" + "gamma" 粘成 "etagamma"。尾部非空时同样要用连接符隔开。
- book/laws 完全不再透传 delimiter,导致反引号声明的自定义切分能力丢失,
  "alpha CUT beta CUT gamma" 不再被切分。

新增 custom_delimiter_config:只把反引号包裹的自定义切分配置透传给
naive_merge,普通值返回 None 由它回退到换行连接。这样按行取段的 book/laws
既能保留自定义切分,又不会把行间换行改写成分隔符配置。

overlap 截断本身仍是按字符截取(既有行为),本次只补回连接符。
@lianbie

lianbie commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

两处都修好了(a0f7e606),谢谢实测。

回归 1(overlap 尾部粘连):确认是我引入的。base 里 add_chunk 收到的已经是 "\n" + sec,所以 overlap 尾部天然带换行隔开;我改成在 add_chunk 内部拼接后,新块分支只写了 prev[start:] + sec,漏了连接符。现在尾部非空时才加:

prev = _remove_pdf_tags(chunks[-1])
start = int(len(prev) * (100 - overlap) / 100.0)
tail = prev[start:]
text = tail + sep + sec if tail and sep else tail + sec

|、chunk_token_num=2、overlapped_percent=25 现在是 ['alpha|beta', 'eta|gamma|delta', 'elta|epsilon|zeta'],与 base 的 ['\nalpha\nbeta', 'eta\ngamma\ndelta', 'elta\nepsilon\nzeta'] 只差在用的是配置的连接符而不是换行。

回归 2(自定义切分丢失):这个是我上一版处理过头了。你说得对,不能直接删掉透传。改成只把反引号声明的自定义切分配置透传出去:

def custom_delimiter_config(delimiter: str) -> str | None:
    custom_delimiters = _extract_custom_delimiters(delimiter)
    if not custom_delimiters:
        return None
    return "".join(f"`{item}`" for item in custom_delimiters)

book.py / laws.py 现在传 custom_delimiter_config(unescape_delimiter(config)):自定义切分配置照常透传(\CUT`→['alpha', 'beta', 'gamma']),普通值得到 None,由 naive_merge` 回退到换行连接,行间换行不再被改写。

对照 base 与修复后的完整输出(book/laws 全部一致):

场景 base 953703b 现在
book | + 换行原文 ['alpha\nbeta\ngamma\ndelta'] 同
book 自定义 `CUT` ['alpha', 'beta', 'gamma'] 同
laws 自定义 `CUT` ['alpha CUT beta CUT gamma'] 同
laws | + 法条 ['第一条\nalpha', '第二条\nbeta'] 同

(laws 那两行本来就走 tree_merge,naive_merge 到不了;两边一致。)

用例:按你说的只补这两类最小回归,共 2 条,在 7eb22f0 上均失败、在 a0f7e60 上通过:

  • test_overlap_tail_is_separated_from_the_appended_fragment
  • test_custom_delimiter_config_still_reaches_line_based_chunkers

chunking 相关 28 个测试全过,ruff 检查与格式化通过。

@xerrors
xerrors merged commit 4ed0c10 into xerrors:main Nov 5, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 默认分块器忽略配置的分隔符,并把空格替换为换行

2 participants