Files
ragflow/internal/ingestion/component/chunker/token_batch1_test.go
Jack a6a67c5ece Chunker: port Python overlapped_percent normalization and BULLET_PATTERN title fallback (#17396)
## Summary

Closes two Chunker migration gaps documented in
`docs/migration_python_go_diff.md`
(diffs **2.6** and **1.7**), improving parity with the Python ingestion
pipeline.

This is the code portion of commit `261e1fd0b` on branch
`fix/batch-3-4`; the
migration document itself is tracked separately (untracked in this
commit).

### Diff 2.6 — `overlapped_percent` missing Python normalization

Python's `normalize_overlapped_percent` (`common/float_utils.py:50-58`)
accepts a
`[0,1)` fraction (the flow-canvas UI validates `[0,1)`), multiplies it
by 100,
`int()`-truncates, and clamps to `[0,90]`. Go previously only accepted a
raw
`[0,90]` percentage and **rejected** out-of-range input, so a Python
config
passing `0.1` (meaning 10%) silently produced ~0% overlap.

Added `normalizeOverlappedPercent`
(`internal/ingestion/component/chunker/common.go`) mirroring the Python
helper:

- parses numbers and numeric strings (mirrors Python `float()`; bad /
`NaN` / `Inf` → `0`),
- `0 < v < 1 → v *= 100`,
- `int()` truncation,
- clamps to `[0,90]`.

Wired into `tokenChunkerParam.Update` (`token.go`); the merge math
(`token.go:705`, `(100-x)/100`) already matched Python.
`TokenChunkerParam.Validate`
(`schema/chunker.go`) now only guards direct struct construction.

Tests: `TestNormalizeOverlappedPercent`, extended
`TestTokenChunker_NewAcceptsPythonOverlappedRange` (adds fraction/clamp
inputs),
new `TestTokenChunker_NormalizesOverlappedPercent`. The existing
reject-test cases
for `<0` / `>90` were removed because they are now normalized/clamped
(Python parity).

### Diff 1.7 — missing `BULLET_PATTERN` title-level fallback

`resolveTitleLevels` (`title.go`) now applies a 4th-level fallback: when
outline +
regex + layout all yield body level, `bulletsCategory` selects the
best-matching
bullet-pattern group (Chinese legal / numbering / Chinese numbering /
English legal
— mirroring `rag/nlp/__init__.py:258-320`) and assigns structural
levels. Guarded by
`allBodyLevel` so it never overrides an existing outline/regex level.

Tests: `TestResolveTitleLevels_BulletFallback` (4 subtests).

### Incidental test adjustments included in the commit

- `token_batch1_test.go`: overlap input changed `0.3` → `30.0` to
reflect the
  post-normalization 30% semantics.
- `real_consumer_test.go`: updated `LoadFromIngestionTask(task)` →
`LoadFromIngestionTask(ctx, task)` for the new context-first signature.

## Verification

`bash build.sh --test ./internal/ingestion/component/...` — chunker +
schema suites
pass, no regression (CGO build).

## Migration doc reference

`docs/migration_python_go_diff.md` §Chunker 1.7 and 2.6 are marked
**Fixed** for
these changes.
2026-07-27 14:42:44 +08:00

171 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package chunker
import (
"reflect"
"regexp"
"strings"
"testing"
"ragflow/internal/ingestion/component/schema"
)
// TestSentenceDelimiterMatchesBangAndQuestion exercises migration diff
// Chunker-2.1: the sentence/clause boundary regex used to split oversized
// sections must also break on ASCII "!" and "?" (Python's default delimiter
// is "\n。"). The legacy Go pattern `(\n|[。;!?]|\.\s)` missed the
// ASCII variants, so English fragments like "Hi!" / "Really?" were not
// treated as boundaries.
func TestSentenceDelimiterMatchesBangAndQuestion(t *testing.T) {
// The package-level sentenceDelimiter (introduced by Fix 2.1) must
// match ASCII bang/question.
if !sentenceDelimiter.MatchString("Hi!") {
t.Errorf("sentenceDelimiter should split on '!': %q", "Hi!")
}
if !sentenceDelimiter.MatchString("Really?") {
t.Errorf("sentenceDelimiter should split on '?': %q", "Really?")
}
// Guard: the OLD pattern must NOT match these, proving the test would
// have failed before the fix.
old := regexp.MustCompile(`(\n|[。;!?]|\.\s)`)
if old.MatchString("Hi!") || old.MatchString("Really?") {
t.Errorf("guard broken: old pattern unexpectedly matches ASCII !/?")
}
}
// TestMergeByTokenSizeFromJSON_OverlapStripsTags exercises migration diff
// Chunker-2.2: when a new chunk is started, its overlap prefix must be taken
// from the previous chunk AFTER remove_tag, otherwise parser tags (e.g.
// "@@1\t2.3##") leak into the overlap region. Mirrors Python
// nlp/__init__.py:1181 (remove_tag applied before overlap).
func TestMergeByTokenSizeFromJSON_OverlapStripsTags(t *testing.T) {
aText := strings.Repeat("word ", 20) + "@@1\t2.3## tail"
items := [][]schema.ChunkDoc{
{
{Text: aText, DocType: "text", CKType: "text", TKNums: intPtr(100)},
{Text: "body", DocType: "text", CKType: "text", TKNums: intPtr(5)},
},
}
got := mergeByTokenSizeFromJSON(items, 128, 30.0)
merged := got[0]
if len(merged) != 2 {
t.Fatalf("want 2 merged chunks (overlap path), got %d", len(merged))
}
// The overlap prefix is prepended to the SECOND chunk. The original
// first chunk legitimately keeps its own parser tag; only the overlap
// region (merged[1]) must be tag-free (diff Chunker-2.2).
if strings.Contains(merged[1].Text, "@@") || strings.Contains(merged[1].Text, "##") {
t.Errorf("overlap prefix leaked parser tag into chunk 1: %q", merged[1].Text)
}
}
// TestMergeByTokenSizeFromJSON_ClampsOverlappedPct locks the review finding
// from yuzhichang (PR #17396): mergeByTokenSizeFromJSON clamps an out-of-range
// overlappedPct to [0,100] so the merge math never yields a negative/inverted
// threshold. Out-of-range values must not panic and must behave identically to
// their clamped-in-range equivalent (150 == 100, -5 == 0, and the same for
// huge magnitudes that would otherwise overflow the float->int slice index).
func TestMergeByTokenSizeFromJSON_ClampsOverlappedPct(t *testing.T) {
items := [][]schema.ChunkDoc{
{
{Text: strings.Repeat("word ", 20), DocType: "text", CKType: "text", TKNums: intPtr(100)},
{Text: "body", DocType: "text", CKType: "text", TKNums: intPtr(5)},
},
}
at100 := mergeByTokenSizeFromJSON(items, 128, 100)
if at100 == nil || len(at100) == 0 {
t.Fatalf("overlappedPct=100: nil/empty result")
}
at150 := mergeByTokenSizeFromJSON(items, 128, 150)
atHuge := mergeByTokenSizeFromJSON(items, 128, 1e300)
if !reflect.DeepEqual(at100, at150) {
t.Errorf("overlappedPct=150 should clamp to 100; output differs from 100")
}
if !reflect.DeepEqual(at100, atHuge) {
t.Errorf("overlappedPct=1e300 should clamp to 100; output differs from 100")
}
at0 := mergeByTokenSizeFromJSON(items, 128, 0)
if at0 == nil || len(at0) == 0 {
t.Fatalf("overlappedPct=0: nil/empty result")
}
atNeg := mergeByTokenSizeFromJSON(items, 128, -5)
atNegHuge := mergeByTokenSizeFromJSON(items, 128, -1e300)
if !reflect.DeepEqual(at0, atNeg) {
t.Errorf("overlappedPct=-5 should clamp to 0; output differs from 0")
}
if !reflect.DeepEqual(at0, atNegHuge) {
t.Errorf("overlappedPct=-1e300 should clamp to 0; output differs from 0")
}
}
// TestMergeByTokenSizeFromJSON_EmptyPrevKeepsChunk exercises migration diff
// Chunker-2.11: merging a non-empty chunk into an empty previous chunk must
// assign the text directly instead of being skipped. The legacy guard
// `if prev.Text != ""` silently dropped the incoming chunk when the previous
// one had empty text. Mirrors Python token_chunker.py:236-239.
func TestMergeByTokenSizeFromJSON_EmptyPrevKeepsChunk(t *testing.T) {
items := [][]schema.ChunkDoc{
{
{Text: "", DocType: "text", CKType: "text", TKNums: intPtr(5)},
{Text: "keepme", DocType: "text", CKType: "text", TKNums: intPtr(5)},
},
}
got := mergeByTokenSizeFromJSON(items, 128, 0)
merged := got[0]
if len(merged) != 1 {
t.Fatalf("want 1 merged chunk, got %d", len(merged))
}
if merged[0].Text != "keepme" {
t.Errorf("empty previous chunk dropped incoming text; got %q", merged[0].Text)
}
}
// TestTakeFromEndRespectsTokenCount and TestTakeFromStartRespectsTokenCount
// exercise migration diff Chunker-2.4: takeFromEnd/takeFromStart used a
// fixed 4-bytes-per-token heuristic which badly over-counts for CJK text
// (≈3 bytes/char, 1-2 tokens/char). They must now count tokens exactly via
// tokenizeStr so the returned slice is close to the requested token budget.
func TestTakeFromEndRespectsTokenCount(t *testing.T) {
const target = 20
s := strings.Repeat("中", 60)
got := takeFromEnd(s, target)
if !strings.HasSuffix(s, got) {
t.Fatalf("takeFromEnd result must be a suffix of input")
}
n := tokenizeStr(got)
if n < target-3 || n > target+3 {
t.Errorf("takeFromEnd(%d tokens) returned slice with %d tokens (want ~%d)", target, n, target)
}
}
func TestTakeFromStartRespectsTokenCount(t *testing.T) {
const target = 20
s := strings.Repeat("中", 60)
got := takeFromStart(s, target)
if !strings.HasPrefix(s, got) {
t.Fatalf("takeFromStart result must be a prefix of input")
}
n := tokenizeStr(got)
if n < target-3 || n > target+3 {
t.Errorf("takeFromStart(%d tokens) returned slice with %d tokens (want ~%d)", target, n, target)
}
}