Files
ragflow/internal/parser/parser/pdf_postprocess.go
Jack 9b0719fa94 fix: Go ingestion migration batch 5 (Parser 1.1/1.7/2.11, Chunker 1.7/1.8/2.6/2.7, Tokenizer 6x fixes) (#17419)
## 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)
2026-07-28 11:12:52 +08:00

312 lines
8.2 KiB
Go

package parser
import (
"math"
"regexp"
"sort"
"strings"
deepdoctype "ragflow/internal/deepdoc/parser/type"
)
var pdfHeaderFooterPattern = regexp.MustCompile(`(?i)^(header|footer|number)$`)
var pdfTOCTitlePattern = regexp.MustCompile(`(?i)^(contents|目录|目次|table of contents|致谢|acknowledge)$`)
type pdfPostProcessOptions struct {
outputFormat string
pageWidth float64
zoom float64
enableMultiColumn bool
flattenMediaToText bool
removeTOC bool
removeHeaderFooter bool
}
func applyPDFPostProcess(result *deepdoctype.ParseResult, opts pdfPostProcessOptions) {
if result == nil {
return
}
sortSectionsByPosition(result)
if opts.enableMultiColumn && opts.pageWidth > 0 {
reorderPDFMultiColumn(result, opts.pageWidth, opts.zoom)
}
if opts.removeTOC {
applyRemoveTOC(result)
}
normalizePDFLayoutTypes(result)
if opts.removeHeaderFooter {
filterPDFHeaderFooter(result)
}
assignPDFDocTypeKeywords(result, opts.flattenMediaToText)
}
func normalizePDFLayoutTypes(result *deepdoctype.ParseResult) {
for i := range result.Sections {
layoutType := strings.TrimSpace(result.Sections[i].LayoutType)
if layoutType == "" {
layoutType = deepdoctype.LayoutTypeText
}
result.Sections[i].LayoutType = layoutType
}
}
func filterPDFHeaderFooter(result *deepdoctype.ParseResult) {
filtered := result.Sections[:0]
for _, s := range result.Sections {
if pdfHeaderFooterPattern.MatchString(strings.TrimSpace(s.LayoutType)) {
continue
}
filtered = append(filtered, s)
}
result.Sections = filtered
}
func assignPDFDocTypeKeywords(result *deepdoctype.ParseResult, flatten bool) {
for i := range result.Sections {
section := &result.Sections[i]
if flatten {
section.DocTypeKwd = "text"
continue
}
switch strings.TrimSpace(section.LayoutType) {
case deepdoctype.LayoutTypeTable:
section.DocTypeKwd = "table"
case deepdoctype.LayoutTypeFigure:
section.DocTypeKwd = "image"
default:
// doc_type_kwd is derived from layout, not from whether a
// section image was cropped. Cropping happens lazily at
// markdown serialization / chunk time, so it must not
// influence classification here (otherwise every positioned
// text box would be mislabeled "image").
section.DocTypeKwd = "text"
}
}
}
// sortSectionsByPosition reorders sections into reading order: page number,
// then vertical position (top), then horizontal position (left). The DeepDoc
// layout engine does not guarantee reading order in its output, so this sort
// ensures the downstream chunker receives items in document order regardless
// of the engine's internal extraction sequence.
func sortSectionsByPosition(result *deepdoctype.ParseResult) {
if result == nil || len(result.Sections) < 2 {
return
}
sort.SliceStable(result.Sections, func(i, j int) bool {
pi, pj := firstSectionPage(result.Sections[i]), firstSectionPage(result.Sections[j])
if pi != pj {
return pi < pj
}
ti, tj := firstSectionTop(result.Sections[i]), firstSectionTop(result.Sections[j])
if math.Abs(ti-tj) > 1e-6 {
return ti < tj
}
return firstSectionLeft(result.Sections[i]) < firstSectionLeft(result.Sections[j])
})
}
// applyRemoveTOC mirrors Python parser.py:663-681 three-way dispatch:
// - No outlines → pattern-based remove_toc on all sections
// - First outline on page 1 → outline-based remove_toc_pdf
// - First outline after page 1 → pattern-based on pages before the first outline
func applyRemoveTOC(result *deepdoctype.ParseResult) {
if result == nil {
return
}
outlines := result.Outlines
if len(outlines) == 0 {
removePDFTOC(result)
return
}
firstOutlinePage := outlines[0].PageNumber
if firstOutlinePage <= 1 {
removePDFTOCByOutlines(result, outlines)
return
}
splitAt := len(result.Sections)
for i, s := range result.Sections {
if firstSectionPage(s) >= firstOutlinePage {
splitAt = i
break
}
}
beforeSplit := &deepdoctype.ParseResult{Sections: result.Sections[:splitAt]}
removePDFTOC(beforeSplit)
result.Sections = append(beforeSplit.Sections, result.Sections[splitAt:]...)
}
func removePDFTOC(result *deepdoctype.ParseResult) {
sections := result.Sections
i := 0
for i < len(sections) {
text := sectionText(sections[i])
if !pdfTOCTitlePattern.MatchString(strings.ToLower(strings.TrimSpace(text))) {
i++
continue
}
sections = append(sections[:i], sections[i+1:]...)
if i >= len(sections) {
break
}
prefix := sectionTextPrefix(sections[i], 3)
for prefix == "" {
sections = append(sections[:i], sections[i+1:]...)
if i >= len(sections) {
break
}
prefix = sectionTextPrefix(sections[i], 3)
}
if i >= len(sections) || prefix == "" {
break
}
sections = append(sections[:i], sections[i+1:]...)
if i >= len(sections) || prefix == "" {
break
}
for j := i; j < len(sections) && j < i+128; j++ {
if !strings.HasPrefix(sectionText(sections[j]), prefix) {
continue
}
sections = append(sections[:i], sections[j:]...)
break
}
}
result.Sections = sections
}
func sectionText(s deepdoctype.Section) string {
return strings.TrimSpace(s.Text)
}
func sectionTextPrefix(s deepdoctype.Section, n int) string {
text := sectionText(s)
if len(text) < n {
return text
}
return text[:n]
}
func removePDFTOCByOutlines(result *deepdoctype.ParseResult, outlines []deepdoctype.Outline) {
if result == nil || len(outlines) == 0 {
return
}
tocPage, contentPage := findPDFTOCPageRange(outlines)
if contentPage <= tocPage {
return
}
filtered := result.Sections[:0]
for _, s := range result.Sections {
page := firstSectionPage(s)
if page >= tocPage && page < contentPage {
continue
}
filtered = append(filtered, s)
}
result.Sections = filtered
}
func findPDFTOCPageRange(outlines []deepdoctype.Outline) (tocPage, contentPage int) {
outer:
for i, o := range outlines {
title := strings.TrimSpace(o.Title)
if idx := strings.Index(title, "@@"); idx >= 0 {
title = strings.TrimSpace(title[:idx])
}
if !pdfTOCTitlePattern.MatchString(strings.ToLower(title)) {
continue
}
tocPage = o.PageNumber
for _, next := range outlines[i+1:] {
if next.Level != o.Level {
continue
}
nextTitle := strings.TrimSpace(next.Title)
if idx := strings.Index(nextTitle, "@@"); idx >= 0 {
nextTitle = strings.TrimSpace(nextTitle[:idx])
}
if pdfTOCTitlePattern.MatchString(strings.ToLower(nextTitle)) {
continue
}
contentPage = next.PageNumber
break outer
}
break
}
return
}
func reorderPDFMultiColumn(result *deepdoctype.ParseResult, pageWidth, _ float64) {
if result == nil || len(result.Sections) < 2 {
return
}
var widths []float64
for _, s := range result.Sections {
if strings.TrimSpace(s.LayoutType) != deepdoctype.LayoutTypeText || len(s.Positions) == 0 {
continue
}
width := s.Positions[0].Right - s.Positions[0].Left
if width > 0 {
widths = append(widths, width)
}
}
if len(widths) == 0 {
return
}
sort.Float64s(widths)
medianWidth := widths[len(widths)/2]
if medianWidth >= pageWidth/2 {
return
}
sort.Slice(result.Sections, func(i, j int) bool {
pi, pj := firstSectionPage(result.Sections[i]), firstSectionPage(result.Sections[j])
if pi != pj {
return pi < pj
}
xi, xj := firstSectionLeft(result.Sections[i]), firstSectionLeft(result.Sections[j])
if math.Abs(xi-xj) > 1e-6 {
return xi < xj
}
return firstSectionTop(result.Sections[i]) < firstSectionTop(result.Sections[j])
})
threshold := medianWidth / 2
for i := len(result.Sections) - 1; i >= 1; i-- {
for j := i - 1; j >= 0; j-- {
if firstSectionPage(result.Sections[j]) != firstSectionPage(result.Sections[j+1]) {
continue
}
if math.Abs(firstSectionLeft(result.Sections[j])-firstSectionLeft(result.Sections[j+1])) >= threshold {
continue
}
if firstSectionTop(result.Sections[j+1]) < firstSectionTop(result.Sections[j]) {
result.Sections[j], result.Sections[j+1] = result.Sections[j+1], result.Sections[j]
}
}
}
}
func firstSectionPage(s deepdoctype.Section) int {
for _, p := range s.Positions {
for _, pn := range p.PageNumbers {
return pn
}
}
return 0
}
func firstSectionLeft(s deepdoctype.Section) float64 {
for _, p := range s.Positions {
return p.Left
}
return 0
}
func firstSectionTop(s deepdoctype.Section) float64 {
for _, p := range s.Positions {
return p.Top
}
return 0
}