mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 13:33:48 +08:00
## Summary
Continuation of the Python→Go ingestion pipeline migration (File →
Parser → Chunker → Extractor → Tokenizer). Fixes cover Parser, Chunker,
and Tokenizer gaps identified. Fix page number (0-indexed and 1-index
mixed before fix; use 1-indexed after fix) and chunk order issues.
### Parser
- **Slides TCADP (1.7):** `pptx_tcadp.go` + TCADP branch in
`pptx_parser.go`/`ppt_parser.go` — PowerPoint files now support
`parse_method="tcadp"` via the TCADP cloud service, matching the
spreadsheet-family TCADP pattern. PPT containers pass `"PPT"` as
fileType (not hardcoded `"PPTX"`).
- **Audio default output_format (2.11):** `defaultSetups()` audio
default changed from `"text"` to `"json"`, aligning with Python
`parser.py:232` and `AllowedOutputFormat["audio"]={"json"}`.
- **PDF VLM enhancement (1.1):** `maybeDispatchPDFVisionEnhancement` in
`pdf_vision_dispatch.go` enriches image/table items with IMAGE2TEXT
model descriptions after PDF parsing, mirroring Python
`enhance_media_sections_with_vision`. Semaphore fix: acquire before
goroutine start to prevent unbounded goroutine creation.
- **json family (2.3):** reclassified as Keep Go — `json_parser.go` is a
functional enhancement, not a parity gap.
- **page number:** changed from "mixed use of 1-indexed & 0-indexed" to
"1-indexed"
### Chunker
- **BULLET_PATTERN fallback (1.7):** 4th-level fallback in
`resolveTitleLevels` (`title.go`) detects bullet/numbered-list patterns
(Chinese legal, numbering, English) when outline + regex levels produce
only bodyLevel. Guarded by `allBodyLevel` to never override existing
structure.
- **Tag/One chunker fields (1.8):** `tag.go` sets `TopInt` from source
row index; `one.go` preserves `Positions`/`PDFPositions` from source
items. TSV multi-line RowNum fix: tracks `contentStart` for correct row
attribution.
- **Overlapped_percent normalization (2.6):**
`NormalizeOverlappedPercent` in `schema/chunker.go` mirrors Python
`common/float_utils.py:50-58` — accepts `[0,1)` fraction or `[0,90]`
percent, normalizes to canonical `[0,90]`.
- **Paragraph splitting (2.7):** aligned to Python flow `naive_merge` —
`CRLF` normalization, `splitKeepingDelimiter` preserves sentence
delimiters, single-section merge with token-budget-governed chunking.
- **chunk order:** sort by reading order
### Tokenizer
- **Phantom chunk filtering (Omission 2):** `isPhantomChunk` + filter
loop in `chunksFromTokenizerUpstream` skips zero-value ChunkDocs.
- **Batch size env var (Omission 3):** `embeddingBatchSize()` reads
`TOKENIZER_EMBEDDING_BATCH_SIZE`, defaults to 16.
- **Summary empty check (Diff 5):** `TrimSpace(s) != ""` → `s != ""`,
matching Python truthy check.
- **chunk_order_int all paths (Diff 8):** set unconditionally before
full_text/embedding branching.
- **Timeout default (Diff 10):** `600s` → `60s`, matching Python
`@timeout(60)`.
- **Small maxTokens truncation (Diff 14):** `truncateForEmbedding`
returns `""` when `maxTokens <= 10`, matching Python.
### Code review fixes
- Semaphore acquire moved before goroutine in `pdf_vision_dispatch.go`
(concurrency control)
- Context propagation in `pptx_tcadp.go` (cancellation support)
- Test resolver leak fix in `media_dispatch_test.go` (defer restore)
- Migration history comments removed per AGENTS.md
## Test plan
```
bash build.sh --test ./internal/parser/parser/... ./internal/ingestion/component/...
```
## Notes
- Migration diff tracking: `docs/migration_python_go_diff.md`
- Remaining gaps: Extractor component only (21 items)
328 lines
10 KiB
Go
328 lines
10 KiB
Go
//
|
|
// 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.
|
|
//
|
|
|
|
// SCOPE (honest) for hierarchy.go:
|
|
//
|
|
// - Implements the HierarchyTitleChunker variant: builds a heading
|
|
// tree from per-line levels, then walks the tree in DFS to emit
|
|
// chunks that respect hierarchical scope (a sub-tree's body
|
|
// chunks include the heading texts of all ancestor nodes).
|
|
//
|
|
// tree-build pass is sequential; the subtree-to-chunk conversion
|
|
// runs across 2 goroutines, then results are merged in DFS
|
|
// traversal order (deterministic, plan §8 R8).
|
|
//
|
|
// - MIRRORS python `_ChunkNode.build_tree` (hierarchy_chunker.py:38-52):
|
|
// a stack-tracked descent into a tree where each node has a
|
|
// level, title indexes (only headings), body indexes (only the
|
|
// body lines that fall under that heading), and child nodes.
|
|
//
|
|
// - DFS path emission (hierarchy_chunker.py:55-83) honours
|
|
// `include_heading_content` and the python leaf-only rule: when
|
|
// include_heading_content is false, only leaf nodes emit.
|
|
//
|
|
// - No PDF-position / deepdoc awareness; structured chunk inputs
|
|
// use the same dfs over text records.
|
|
package chunker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"ragflow/internal/agent/runtime"
|
|
"ragflow/internal/common"
|
|
"ragflow/internal/ingestion/component/globals"
|
|
)
|
|
|
|
const ComponentNameHierarchyTitleChunker = "HierarchyTitleChunker"
|
|
|
|
// chunkNode mirrors python `_ChunkNode` (hierarchy_chunker.py:22-32).
|
|
type chunkNode struct {
|
|
level int
|
|
titleIndexes []int
|
|
bodyIndexes []int
|
|
children []*chunkNode
|
|
}
|
|
|
|
func (n *chunkNode) addBodyIndex(idx int) {
|
|
n.bodyIndexes = append(n.bodyIndexes, idx)
|
|
}
|
|
|
|
func (n *chunkNode) addChild(c *chunkNode) {
|
|
n.children = append(n.children, c)
|
|
}
|
|
|
|
// buildTree mirrors `_ChunkNode.build_tree` (hierarchy_chunker.py:38-52):
|
|
// descend into nested headings via a stack. Lines whose level exceeds
|
|
// `depth` are body lines on the topmost stack frame.
|
|
func buildTree(indexedLines []indexedLine, depth int) *chunkNode {
|
|
root := &chunkNode{level: 0}
|
|
stack := []*chunkNode{root}
|
|
for _, il := range indexedLines {
|
|
lvl, idx := il.level, il.index
|
|
if lvl > depth {
|
|
stack[len(stack)-1].addBodyIndex(idx)
|
|
continue
|
|
}
|
|
// Pop until parent level is strictly less than lvl.
|
|
for len(stack) > 1 && lvl <= stack[len(stack)-1].level {
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
node := &chunkNode{level: lvl, titleIndexes: []int{idx}}
|
|
stack[len(stack)-1].addChild(node)
|
|
stack = append(stack, node)
|
|
}
|
|
return root
|
|
}
|
|
|
|
type indexedLine struct {
|
|
level int
|
|
index int
|
|
}
|
|
|
|
// getPaths mirrors `_ChunkNode._dfs` (hierarchy_chunker.py:61-83).
|
|
// Returns one index-list per chunk, in DFS order. `path_titles` is
|
|
// updated as the recursion descends and used as the leading slice
|
|
// for each chunk path.
|
|
func (n *chunkNode) getPaths(paths *[][]int, titles []int, depth int, includeHeading bool) []int {
|
|
if n.level == 0 && len(n.bodyIndexes) > 0 {
|
|
combined := append(append([]int(nil), titles...), n.bodyIndexes...)
|
|
*paths = append(*paths, combined)
|
|
}
|
|
|
|
var pathTitles []int
|
|
if includeHeading {
|
|
if n.level >= 1 && n.level <= depth {
|
|
pathTitles = append(append([]int(nil), titles...), n.titleIndexes...)
|
|
} else {
|
|
pathTitles = titles
|
|
}
|
|
if len(n.bodyIndexes) > 0 && n.level >= 1 && n.level <= depth {
|
|
combined := append(append([]int(nil), pathTitles...), n.bodyIndexes...)
|
|
*paths = append(*paths, combined)
|
|
} else if len(n.children) == 0 && n.level >= 1 && n.level <= depth {
|
|
combined := append([]int(nil), pathTitles...)
|
|
*paths = append(*paths, combined)
|
|
}
|
|
} else {
|
|
if n.level >= 1 && n.level <= depth {
|
|
pathTitles = append(append(append([]int(nil), titles...), n.titleIndexes...), n.bodyIndexes...)
|
|
} else {
|
|
pathTitles = titles
|
|
}
|
|
if len(n.children) == 0 && n.level >= 1 && n.level <= depth {
|
|
combined := append([]int(nil), pathTitles...)
|
|
*paths = append(*paths, combined)
|
|
}
|
|
}
|
|
|
|
for _, child := range n.children {
|
|
child.getPaths(paths, pathTitles, depth, includeHeading)
|
|
}
|
|
return pathTitles
|
|
}
|
|
|
|
// invokeHierarchy runs the HierarchyTitleChunker strategy.
|
|
func invokeHierarchy(parentCtx context.Context, db *gorm.DB, inputs map[string]any, p *titleChunkerParam) (map[string]any, error) {
|
|
records := extractLineRecords(inputs)
|
|
common.Debug("chunker stage",
|
|
zap.String("component", "Chunker"),
|
|
zap.String("variant", "hierarchy"),
|
|
zap.Int("records", len(records)),
|
|
)
|
|
if len(records) == 0 {
|
|
return emptyOutputs(), nil
|
|
}
|
|
ctx := newLevelContext(records, outlineFromInputs(inputs), p)
|
|
levels := ctx.Levels()
|
|
// Count heading level distribution for debugging.
|
|
headingCounts := make(map[int]int)
|
|
for _, lvl := range levels {
|
|
if lvl < bodyLevel {
|
|
headingCounts[lvl]++
|
|
}
|
|
}
|
|
bodyCount := len(levels)
|
|
for _, c := range headingCounts {
|
|
bodyCount -= c
|
|
}
|
|
common.Debug("chunker stage",
|
|
zap.String("component", "Chunker"),
|
|
zap.String("variant", "hierarchy"),
|
|
zap.Int("records", len(records)),
|
|
zap.Int("body_level", bodyCount),
|
|
zap.Any("heading_levels", headingCounts),
|
|
)
|
|
|
|
// Mirror python HierarchyTitleChunker.build_chunks: accumulate
|
|
// contiguous text records into a run; on each non-text record flush
|
|
// the run (build the heading tree + emit its paths) and emit the
|
|
// non-text record as its own single-record group. A final flush
|
|
// handles the trailing run.
|
|
//
|
|
// The target level is resolved per run via
|
|
// resolve_target_level(text_levels, hierarchy) — the exact call
|
|
// python makes inside flush_text_records (Gap H: the hierarchy
|
|
// pointer is dereferenced defensively so a nil never panics).
|
|
var recordGroups [][]lineRecord
|
|
var textRun []lineRecord
|
|
var textLevels []int
|
|
|
|
flush := func() {
|
|
if len(textRun) == 0 {
|
|
return
|
|
}
|
|
h := 1
|
|
if p.Hierarchy != nil {
|
|
h = *p.Hierarchy
|
|
}
|
|
targetLevel := resolveTargetLevel(textLevels, h)
|
|
if targetLevel == 0 {
|
|
// resolve_target_level returned None (no heading levels in
|
|
// the run): emit the whole run as a single group, exactly
|
|
// like python's `record_groups.append(text_records.copy())`.
|
|
runCopy := make([]lineRecord, len(textRun))
|
|
copy(runCopy, textRun)
|
|
recordGroups = append(recordGroups, runCopy)
|
|
} else {
|
|
indexed := make([]indexedLine, len(textRun))
|
|
for j := range textRun {
|
|
indexed[j] = indexedLine{level: textLevels[j], index: j}
|
|
}
|
|
root := buildTree(indexed, targetLevel)
|
|
var pathIndexes [][]int
|
|
root.getPaths(&pathIndexes, nil, targetLevel, p.IncludeHeadingContent)
|
|
for _, path := range pathIndexes {
|
|
if len(path) == 0 {
|
|
continue
|
|
}
|
|
grp := make([]lineRecord, len(path))
|
|
for k, idx := range path {
|
|
grp[k] = textRun[idx]
|
|
}
|
|
recordGroups = append(recordGroups, grp)
|
|
}
|
|
}
|
|
textRun = textRun[:0]
|
|
textLevels = textLevels[:0]
|
|
}
|
|
|
|
for i, rec := range records {
|
|
if rec.isText() {
|
|
textRun = append(textRun, rec)
|
|
textLevels = append(textLevels, levels[i])
|
|
continue
|
|
}
|
|
flush()
|
|
recordGroups = append(recordGroups, []lineRecord{rec})
|
|
}
|
|
flush()
|
|
common.Debug("chunker stage",
|
|
zap.String("component", "Chunker"),
|
|
zap.String("variant", "hierarchy"),
|
|
zap.Int("record_groups", len(recordGroups)),
|
|
)
|
|
|
|
chunks := buildChunksFromRecordGroups(recordGroups, p, isPlainTextFormat(inputs))
|
|
common.Debug("chunker stage",
|
|
zap.String("component", "Chunker"),
|
|
zap.String("variant", "hierarchy"),
|
|
zap.Int("chunks", len(chunks)),
|
|
zap.Bool("plain_text", isPlainTextFormat(inputs)),
|
|
)
|
|
|
|
// On-demand PDF preview cropping for image/table/text chunks,
|
|
// mirroring the TokenChunker JSON path (token.go:513). Best-effort:
|
|
// a missing or unreadable PDF simply skips cropping.
|
|
if upstream, uErr := decodeChunkerFromUpstream(inputs); uErr == nil {
|
|
engine, eErr := newPDFEngineFromUpstream(parentCtx, db, upstream)
|
|
if eErr != nil {
|
|
slog.Warn("HierarchyTitleChunker: could not open PDF for on-demand cropping", "err", eErr)
|
|
}
|
|
if engine != nil {
|
|
defer engine.Close()
|
|
chunks = cropTitleChunks(parentCtx, engine, chunks)
|
|
}
|
|
}
|
|
|
|
if len(chunks) == 0 {
|
|
return emptyOutputs(), nil
|
|
}
|
|
out := map[string]any{
|
|
"output_format": "chunks",
|
|
"chunks": chunks,
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// HierarchyTitleChunkerComponent is the standalone variant entry point.
|
|
type HierarchyTitleChunkerComponent struct {
|
|
name string
|
|
param titleChunkerParam
|
|
}
|
|
|
|
// NewHierarchyTitleChunker constructs the variant component with
|
|
// method pre-set to "hierarchy".
|
|
func NewHierarchyTitleChunker(params map[string]any) (runtime.Component, error) {
|
|
conf := map[string]any{"method": "hierarchy"}
|
|
for k, v := range params {
|
|
conf[k] = v
|
|
}
|
|
p := defaultsTitle()
|
|
p.Update(conf)
|
|
if err := p.TitleChunkerParam.Validate(); err != nil {
|
|
return nil, fmt.Errorf("HierarchyTitleChunker: %w", err)
|
|
}
|
|
return &HierarchyTitleChunkerComponent{
|
|
name: ComponentNameHierarchyTitleChunker,
|
|
param: p,
|
|
}, nil
|
|
}
|
|
|
|
func (c *HierarchyTitleChunkerComponent) Inputs() map[string]string {
|
|
return ChunkerInputs
|
|
}
|
|
func (c *HierarchyTitleChunkerComponent) Outputs() map[string]string {
|
|
return ChunkerOutputs
|
|
}
|
|
|
|
func (c *HierarchyTitleChunkerComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map[string]any) (map[string]any, error) {
|
|
if inputs == nil {
|
|
inputs = map[string]any{}
|
|
}
|
|
// `name` is read from the workflow-wide Globals bag (seeded at
|
|
// pipeline start, published by the File component), not from the
|
|
// upstream output map.
|
|
name := globals.GlobalOrInput(ctx, inputs, "name", "")
|
|
if name == "" {
|
|
return map[string]any{
|
|
"output_format": "chunks",
|
|
"chunks": []map[string]any{},
|
|
"_ERROR": "HierarchyTitleChunker: missing required upstream field \"name\"",
|
|
}, nil
|
|
}
|
|
return invokeHierarchy(ctx, db, withName(inputs, name), &c.param)
|
|
}
|
|
|
|
// init registers HierarchyTitleChunker under CategoryIngestion.
|
|
func init() {
|
|
MustRegisterChunker(ComponentNameHierarchyTitleChunker)
|
|
}
|