// // 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. // // QAChunker extracts question-answer pairs from parsed content. // // Input formats and extraction strategies: // - Text (txt, csv) → delimiter-based Q&A (comma or tab) // - Markdown (md) → heading-based Q&A // - HTML (xlsx/xls) → table-based Q&A (first two columns) // - JSON (pdf, docx) → delimiter-based on structured text sections // // Every Q&A pair becomes a single chunk with content_with_weight // formatted as "Question: {q}\tAnswer: {a}". package chunker import ( "context" "encoding/csv" "fmt" "html" "regexp" "strings" "ragflow/internal/agent/runtime" "ragflow/internal/ingestion/component/schema" "ragflow/internal/tokenizer" ) const ComponentNameQAChunker = "QAChunker" type qaChunkerParam struct{} func (p *qaChunkerParam) Update(conf map[string]any) {} func (qaChunkerParam) Defaults() qaChunkerParam { return qaChunkerParam{} } func (qaChunkerParam) Validate() error { return nil } type QAChunkerComponent struct { name string param qaChunkerParam } func NewQAChunker(params map[string]any) (runtime.Component, error) { p := qaChunkerParam{}.Defaults() (&p).Update(params) if err := p.Validate(); err != nil { return nil, err } return &QAChunkerComponent{ name: ComponentNameQAChunker, param: p, }, nil } func (c *QAChunkerComponent) Inputs() map[string]string { return ChunkerInputs } func (c *QAChunkerComponent) Outputs() map[string]string { return ChunkerOutputs } func (c *QAChunkerComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) { return c.invoke(ctx, inputs) } func (c *QAChunkerComponent) invoke(_ context.Context, inputs map[string]any) (map[string]any, error) { if inputs == nil { return emptyOutputs(), nil } upstream, err := decodeChunkerFromUpstream(inputs) if err != nil { return map[string]any{ "output_format": "chunks", "chunks": []map[string]any{}, "_ERROR": fmt.Sprintf("Input error: %v", err), }, nil } var qaPairs []qaPair switch upstream.OutputFormat { case schema.PayloadFormatHTML: qaPairs = extractQATable(stringPtrVal(upstream.HTMLResult)) case schema.PayloadFormatMarkdown: qaPairs = extractQAMarkdown(stringPtrVal(upstream.MarkdownResult)) case schema.PayloadFormatText: qaPairs = extractQAText(stringPtrVal(upstream.TextResult)) default: qaPairs = extractQAJSON(upstream.JSONResult) } chunks := make([]schema.ChunkDoc, 0, len(qaPairs)) for _, pair := range qaPairs { contentLTKS, _ := tokenizer.Tokenize(pair.Question) contentSMLTKS, _ := tokenizer.FineGrainedTokenize(contentLTKS) chunk := schema.ChunkDoc{ ContentWithWeight: fmt.Sprintf("Question: %s\tAnswer: %s", rmQAPrefix(pair.Question), rmQAPrefix(pair.Answer)), DocType: "text", ContentLtks: contentLTKS, ContentSmLtks: contentSMLTKS, } chunks = append(chunks, chunk) } return chunkOutputs(chunks), nil } type qaPair struct { Question string Answer string } var rmQAPrefixRe = regexp.MustCompile(`^(问题|答案|回答|user|assistant|Q|A|Question|Answer|问|答)[\t:: ]+`) func rmQAPrefix(txt string) string { return strings.TrimSpace(rmQAPrefixRe.ReplaceAllString(txt, "")) } func stringPtrVal(s *string) string { if s == nil { return "" } return *s } // --------------------------------------------------------------------------- // HTML / spreadsheet QA extraction // --------------------------------------------------------------------------- var htmlTR = regexp.MustCompile(`(?i)]*>(.*?)`) var htmlTD = regexp.MustCompile(`(?i)]*>(.*?)`) var htmlTag = regexp.MustCompile(`<[^>]+>`) func extractQATable(htmlStr string) []qaPair { if htmlStr == "" { return nil } rows := htmlTR.FindAllStringSubmatch(htmlStr, -1) pairs := make([]qaPair, 0, len(rows)) for _, row := range rows { cells := htmlTD.FindAllStringSubmatch(row[1], -1) var texts []string for _, cell := range cells { t := html.UnescapeString(htmlTag.ReplaceAllString(cell[1], "")) t = strings.TrimSpace(t) if t != "" { texts = append(texts, t) } } if len(texts) >= 2 { pairs = append(pairs, qaPair{Question: texts[0], Answer: texts[1]}) } } return pairs } // --------------------------------------------------------------------------- // Markdown QA extraction // --------------------------------------------------------------------------- var mdHeading = regexp.MustCompile(`^(#{1,6})\s+`) func extractQAMarkdown(md string) []qaPair { if md == "" { return nil } lines := strings.Split(md, "\n") var pairs []qaPair var questionStack, levelStack []string var answer []string codeBlock := false flushAnswer := func() { joined := strings.TrimSpace(strings.Join(answer, "\n")) if joined != "" && len(questionStack) > 0 { sumQ := strings.Join(questionStack, "\n") pairs = append(pairs, qaPair{Question: sumQ, Answer: joined}) } answer = nil } for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "```") { codeBlock = !codeBlock } if codeBlock { answer = append(answer, line) continue } m := mdHeading.FindStringSubmatch(line) if m == nil || len(m[1]) > 6 { answer = append(answer, line) continue } flushAnswer() level := m[1] question := strings.TrimSpace(line[len(m[0]):]) for len(levelStack) > 0 && len(level) <= len(levelStack[len(levelStack)-1]) { questionStack = questionStack[:len(questionStack)-1] levelStack = levelStack[:len(levelStack)-1] } questionStack = append(questionStack, question) levelStack = append(levelStack, level) } flushAnswer() return pairs } // --------------------------------------------------------------------------- // Text / delimiter-based QA extraction (txt, csv) // --------------------------------------------------------------------------- func extractQAText(text string) []qaPair { if text == "" { return nil } lines := strings.Split(text, "\n") delimiter := detectDelimiter(lines) var pairs []qaPair var question, answer string for _, line := range lines { if strings.TrimSpace(line) == "" { continue } parts := splitQA(line, delimiter) if len(parts) != 2 { if question != "" { answer += "\n" + line } continue } if question != "" && answer != "" { pairs = append(pairs, qaPair{Question: strings.TrimSpace(question), Answer: strings.TrimSpace(answer)}) } question = parts[0] answer = parts[1] } if question != "" { pairs = append(pairs, qaPair{Question: strings.TrimSpace(question), Answer: strings.TrimSpace(answer)}) } return pairs } func detectDelimiter(lines []string) string { comma, tab := 0, 0 for _, line := range lines { if len(strings.Split(line, ",")) == 2 { comma++ } if len(strings.Split(line, "\t")) == 2 { tab++ } } if tab >= comma { return "\t" } return "," } func splitQA(line, delimiter string) []string { if delimiter == "\t" { parts := strings.Split(line, "\t") if len(parts) == 2 { return parts } return []string{line} } r := csv.NewReader(strings.NewReader(line)) r.Comma = ',' r.LazyQuotes = true records, err := r.Read() if err != nil || len(records) != 2 { return []string{line} } return records } // --------------------------------------------------------------------------- // JSON / structured QA extraction // --------------------------------------------------------------------------- func extractQAJSON(items []schema.ChunkDoc) []qaPair { var pairs []qaPair for _, item := range items { txt, _ := itemText(item) if txt == "" { continue } tmp := extractQAText(txt) pairs = append(pairs, tmp...) } return pairs } func init() { MustRegisterChunker(ComponentNameQAChunker) }