// // 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 implements the GroupTitleChunker variant: aggregates adjacent // SCOPE (honest) for group.go: // // - Implements the GroupTitleChunker variant: aggregates adjacent // text records into chunks that span multiple body records while // staying inside one heading section. // // Heading detection stays sequential; grouping work is local to // one invocation. // // - MIRRORS python `_build_section_ids` + `GroupTitleChunker.build_chunks`: // consecutive records with the same (target_level-derived) sec_id // are merged up to MIN_GROUP_TOKENS / MAX_GROUP_TOKENS, then // emitted as one chunk per group. // // - No PDF-position merge is performed (mirrors TokenChunker SCOPE // notes — those land with the deepdoc/parser port). package chunker import ( "context" "encoding/json" "fmt" "log/slog" "regexp" "sort" "strings" "go.uber.org/zap" "gorm.io/gorm" "ragflow/internal/agent/runtime" "ragflow/internal/common" "ragflow/internal/ingestion/component/globals" "ragflow/internal/ingestion/component/schema" "ragflow/internal/tokenizer" ) const ComponentNameGroupTitleChunker = "GroupTitleChunker" // MIN_GROUP_TOKENS / MAX_GROUP_TOKENS mirror the python constants in // group_chunker.py:22-23. They drive the merge heuristic for adjacent // text records. const ( minGroupTokens = 32 maxGroupTokens = 1024 ) // resolveTargetLevel mirrors common.py:resolve_target_level: pick the // n-th smallest heading level from the per-line `levels` vector. func resolveTargetLevel(levels []int, hierarchy int) int { tiers := make([]int, 0, len(levels)) seen := make(map[int]bool) for _, l := range levels { if l > 0 && l < bodyLevel && !seen[l] { seen[l] = true tiers = append(tiers, l) } } if len(tiers) == 0 { return 0 } // ascending for i := 1; i < len(tiers); i++ { for j := i; j > 0 && tiers[j-1] > tiers[j]; j-- { tiers[j-1], tiers[j] = tiers[j], tiers[j-1] } } if hierarchy < 1 { hierarchy = 1 } if hierarchy > len(tiers) { hierarchy = len(tiers) } return tiers[hierarchy-1] } // _buildSectionIDs computes, for each input level, the section id // (`sid`) under which that record falls. Each heading encounter // increments sid by 1; body lines share the active sid. func buildSectionIDs(levels []int, targetLevel int) []int { secIDs := make([]int, len(levels)) sid := 0 for i, lvl := range levels { if i > 0 && targetLevel > 0 && lvl <= targetLevel { sid++ } secIDs[i] = sid } return secIDs } // invokeGroup runs the GroupTitleChunker strategy against the // supplied inputs. Detected headings + adjacent merges happen in two // goroutines (heading detection sequential, then a fan-out over // record-buckets for the merge pass). func invokeGroup(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", "group"), 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", "group"), zap.Int("records", len(records)), zap.Int("body_level", bodyCount), zap.Any("heading_levels", headingCounts), ) // Mirror python group_chunker._resolve_group_target_level: when // `hierarchy` is unset the target level is `most_level` directly // (NOT resolve_target_level — that would re-rank the distinct // heading levels and pick the wrong depth when the heading levels // are not contiguous from 1). When `hierarchy` is set, it defers to // resolve_target_level. targetLevel := resolveGroupTargetLevel(levels, p, ctx.mostLevel) secIDs := buildSectionIDs(levels, targetLevel) groups := groupRecords(records, secIDs, p) common.Debug("chunker stage", zap.String("component", "Chunker"), zap.String("variant", "group"), zap.Int("groups", len(groups)), ) chunks := buildChunksFromRecordGroups(groups, p, isPlainTextFormat(inputs)) common.Debug("chunker stage", zap.String("component", "Chunker"), zap.String("variant", "group"), 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("GroupTitleChunker: 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 } // groupRecords mirrors `GroupTitleChunker.build_chunks`: merges // adjacent text records while staying inside the same logical section // (matching last_sid) and token-budget constraints. func groupRecords(records []lineRecord, secIDs []int, p *titleChunkerParam) [][]lineRecord { if len(records) == 0 { return nil } var recordGroups [][]lineRecord var currentGroup []lineRecord tkCnt := 0 lastSID := -2 for i, rec := range records { secID := secIDs[i] if !rec.isText() { if len(currentGroup) > 0 { recordGroups = append(recordGroups, append([]lineRecord(nil), currentGroup...)) } recordGroups = append(recordGroups, []lineRecord{rec}) currentGroup = currentGroup[:0] tkCnt = 0 lastSID = -2 continue } text := trim(rec.text) if text == "" { continue } tokenCount := tokenizer.NumTokensFromString(text) shouldMerge := len(currentGroup) > 0 && currentGroup[0].isText() && (tkCnt < minGroupTokens || (tkCnt < maxGroupTokens && secID == lastSID)) if shouldMerge { currentGroup = append(currentGroup, rec) tkCnt += tokenCount } else { if len(currentGroup) > 0 { recordGroups = append(recordGroups, append([]lineRecord(nil), currentGroup...)) } currentGroup = []lineRecord{rec} tkCnt = tokenCount } lastSID = secID } if len(currentGroup) > 0 { recordGroups = append(recordGroups, currentGroup) } return recordGroups } // joinGroupText mirrors python's `"".join(record["text"] + "\n" for // record in records)` — every record's text followed by a newline // (including the last), matching the python text join exactly. func joinGroupText(g []lineRecord) string { var sb strings.Builder for _, r := range g { sb.WriteString(r.text) sb.WriteByte('\n') } return sb.String() } // isPlainTextFormat mirrors the `output_format in ["markdown", "text", // "html"]` branch of common.py:build_chunks_from_record_groups. Plain // payloads emit only "text"; structured payloads (chunks/json) also // carry doc_type_kwd and img_id. func isPlainTextFormat(inputs map[string]any) bool { if f, ok := inputs["output_format"].(string); ok { return f == "markdown" || f == "text" || f == "html" } return false } // buildChunksFromRecordGroups mirrors common.py:build_chunks_from_record_groups // (minus the deepdoc-only remove_tag / merge_pdf_positions steps): // - plain payloads: {"text": joined} // - structured payloads: text plus doc_type_kwd / img_id from the // group's leading record. // // root_chunk_as_heading is applied here, exactly as python does (post // materialisation): the root chunk's text is prepended to every // following chunk and the root chunk is dropped. func buildChunksFromRecordGroups(groups [][]lineRecord, p *titleChunkerParam, plain bool) []map[string]any { chunks := make([]map[string]any, 0, len(groups)) for _, g := range groups { if len(g) == 0 { continue } // Strip parser-emitted position tags (`@@...##`) from the // joined text (diff 1.6 / 2.8). Mirrors common.py:255 // `RAGFlowPdfParser.remove_tag("".join(...))`. chunk := map[string]any{"text": removeTag(joinGroupText(g))} if !plain { first := g[0] if first.docType != "" { chunk["doc_type_kwd"] = first.docType } if first.imgID != nil { chunk["img_id"] = *first.imgID } } // Merge PDF coordinate matrices across the merged records // instead of keeping only the leading record's (diff 1.6). // Mirrors pdf_chunk_metadata.py:127 merge_pdf_positions. var pdfSrc, posSrc []json.RawMessage for _, r := range g { if len(r.pdfPositions) > 0 { pdfSrc = append(pdfSrc, r.pdfPositions) } if len(r.positions) > 0 { posSrc = append(posSrc, r.positions) } } if m := mergePositionMatrix(pdfSrc...); m != nil { chunk["_pdf_positions"] = m } if m := mergePositionMatrix(posSrc...); m != nil { chunk["positions"] = m } chunks = append(chunks, chunk) } if p.RootChunkAsHeading && len(chunks) > 1 { rootText := toString(chunks[0]["text"]) for i := 1; i < len(chunks); i++ { chunks[i]["text"] = rootText + "\n" + toString(chunks[i]["text"]) } chunks = chunks[1:] } return chunks } // posTagRemove matches parser-emitted position tags of the form // `@@\t\t\t\t##`. Mirrors Python // pdf_parser.py:1934 `re.sub(r"@@[\t0-9.-]+?##", "", txt)`. var posTagRemove = regexp.MustCompile(`@@[\t0-9.-]+?##`) // removeTag strips parser-emitted position tags from a chunk's text. // Mirrors deepdoc RAGFlowPdfParser.remove_tag. func removeTag(text string) string { return posTagRemove.ReplaceAllString(text, "") } // mergePositionMatrix aggregates multiple PDF coordinate matrices into a // single de-duplicated, sorted matrix. Mirrors Python // pdf_chunk_metadata.py:127 merge_pdf_positions: rows are 5-tuples // [page,left,right,top,bottom]; duplicates (by the first five columns) // are dropped and rows are sorted by (page, top, left). Returns nil when // no source carries any usable coordinates. func mergePositionMatrix(sources ...json.RawMessage) [][]float64 { var out [][]float64 seen := make(map[string]bool) for _, src := range sources { if len(src) == 0 { continue } var mat [][]float64 if err := json.Unmarshal(src, &mat); err != nil { continue } for _, row := range mat { if len(row) < 5 { continue } key := fmt.Sprintf("%v|%v|%v|%v|%v", row[0], row[1], row[2], row[3], row[4]) if seen[key] { continue } seen[key] = true out = append(out, row) } } if len(out) == 0 { return nil } sort.Slice(out, func(i, j int) bool { if out[i][0] != out[j][0] { return out[i][0] < out[j][0] } if out[i][3] != out[j][3] { return out[i][3] < out[j][3] } return out[i][1] < out[j][1] }) return out } // extractLineRecords reads the chunker inputs in the same order the // python BaseTitleChunker.extract_line_records uses: // // 1. If upstream emitted chunks (output_format == "chunks") OR // upstream emitted JSON, normalise from the list payload. // 2. Otherwise, treat text/markdown/html as a "one record per line" // stream (preserving indentation for non-text formats, strip-only // for the text format). func extractLineRecords(inputs map[string]any) []lineRecord { if docs := chunksFromInputs(inputs); docs != nil { return recordsFromStructured(docs) } text, _ := stringFromInputs(inputs, "text", "content", "markdown", "html") if text == "" { return nil } return lineRecordsFromText(text) } func recordsFromStructured(items []schema.ChunkDoc) []lineRecord { out := make([]lineRecord, 0, len(items)) for _, it := range items { text := itemTextOrFallback(it) if text == "" { continue } dt := it.DocType if dt == "" { dt = "text" } var imgID *string if it.ImgID != "" { img := it.ImgID imgID = &img } meta := make(map[string]any) if it.ContentLtks != "" { meta["content_ltks"] = it.ContentLtks } if it.ContentSmLtks != "" { meta["content_sm_ltks"] = it.ContentSmLtks } if it.ContentWithWeight != "" { meta["content_with_weight"] = it.ContentWithWeight } if it.TitleTks != "" { meta["title_tks"] = it.TitleTks } if it.TitleSmTks != "" { meta["title_sm_tks"] = it.TitleSmTks } for k, v := range it.Extra { meta[k] = json.RawMessage(v) } out = append(out, lineRecord{ text: text, docType: dt, imgID: imgID, layout: it.Layout, ckType: it.CKType, pdfPositions: it.PDFPositions, positions: it.Positions, parentMeta: meta, }) } return out } // resolveGroupTargetLevel mirrors group_chunker._resolve_group_target_level: // when `hierarchy` is set (>0) the target depth is resolve_target_level, // otherwise it is the frequency-derived `most_level` directly. func resolveGroupTargetLevel(levels []int, p *titleChunkerParam, mostLevel int) int { if p.Hierarchy != nil && *p.Hierarchy > 0 { return resolveTargetLevel(levels, *p.Hierarchy) } return mostLevel } // GroupTitleChunkerComponent is the standalone variant entry point. // It is registered separately so canvas authors can pick the strategy // directly. TitleChunker's dispatcher routes to invokeGroup / // invokeHierarchy as well. type GroupTitleChunkerComponent struct { name string param titleChunkerParam } // NewGroupTitleChunker constructs the variant component with method // pre-set to "group". func NewGroupTitleChunker(params map[string]any) (runtime.Component, error) { conf := map[string]any{"method": "group"} for k, v := range params { conf[k] = v } p := defaultsTitle() p.Update(conf) if err := p.TitleChunkerParam.Validate(); err != nil { return nil, fmt.Errorf("GroupTitleChunker: %w", err) } return &GroupTitleChunkerComponent{ name: ComponentNameGroupTitleChunker, param: p, }, nil } func (c *GroupTitleChunkerComponent) Inputs() map[string]string { return ChunkerInputs } func (c *GroupTitleChunkerComponent) Outputs() map[string]string { return ChunkerOutputs } func (c *GroupTitleChunkerComponent) 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": "GroupTitleChunker: missing required upstream field \"name\"", }, nil } return invokeGroup(ctx, db, withName(inputs, name), &c.param) } // init registers GroupTitleChunker under CategoryIngestion. func init() { MustRegisterChunker(ComponentNameGroupTitleChunker) }