mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-03 14:27:32 +08:00
## Summary Six sites used to read the same `parser_config.delimiter` field with divergent grammars: - `rag.nlp.get_delimiters` (PDF/DOCX/HTML/EPUB/JSON/CSV/XLSX/email/book) - `rag.nlp.naive_merge` (custom-delimiter branch) - `rag.nlp.naive_merge_with_images` - `rag.nlp._build_cks` - `deepdoc.parser.txt_parser.parser_txt` (.txt, code) - `deepdoc.parser.markdown_parser.MarkdownElementExtractor.get_delimiters` The six implementations disagreed on bare-vs-wrapped chars, dedupe, sort order, CRLF normalization, and `re.I` (#17384). The shipped default `` `\n!?;。;!?` `` was a no-op for `.md` because the markdown path only matched backtick-wrapped tokens. ## Changes - **new:** `rag/nlp/delim.py` with `parse_delimiter_field` and `compile_delimiter_pattern`. Single source of truth. CRLF normalization at the top; longest-first stable sort; insertion-ordered dedupe; no `re.I`. - **refactor:** all six call sites delegate to the helper. - `rag/nlp/__init__.py::get_delimiters` becomes a thin shim. - `deepdoc/parser/txt_parser.py::parser_txt` drops the `[encode/decode/unicode_escape]` round-trip. - `deepdoc/parser/markdown_parser.py::get_delimiters` honors bare chars (fixes [1]). - **tests:** `test/unit_test/rag/test_delim.py` (85 tests) — helper, acceptance table, frontend parity, static guard against re-inlining. - **tests:** `test/unit_test/rag/test_delimiter_case_sensitive.py` (from #17386) updated to retarget the static check at the new helper + AST-based broader scan. ## Acceptance criteria - All six sites produce the same regex pattern for the same input. - Shipped default keeps working for `.txt` / `.pdf` / `.docx`. - Shipped default for `.md` now splits (was a silent no-op). - Tooltip example `` `\n##;` `` produces three effective delimiters regardless of file type. - Bare whitespace inputs split on every occurrence. - Backtick-wrapped whitespace splits only on the exact N-char sequence. - CRLF-line-ending documents split identically to LF-line-ending documents. - 123 tests pass (85 new + 38 existing). ## Rebase protocol As #17385 and #17386 evolve, this branch will be rebased on top. The only overlap between this PR's diff and the other two is `test_delimiter_case_sensitive.py`, where #17383 modifies the static check to point at the new helper location. --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
222 lines
7.0 KiB
Go
222 lines
7.0 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.
|
|
//
|
|
|
|
package chunker
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"ragflow/internal/agent/runtime"
|
|
"ragflow/internal/ingestion/component/schema"
|
|
"ragflow/internal/parser/chunk"
|
|
"ragflow/internal/tokenizer"
|
|
)
|
|
|
|
// newChunkerByName dispatches the DSL name to a typed constructor.
|
|
// Centralised here so each chunker file only needs an init() that
|
|
// declares its registered name (see register.go). The returned
|
|
// runtime.Component interface is satisfied directly by each
|
|
// constructor (NewTokenChunker etc.) — no intermediate assertion
|
|
// is needed.
|
|
func newChunkerByName(name string, params map[string]any) (runtime.Component, error) {
|
|
switch name {
|
|
case ComponentNameTokenChunker:
|
|
return NewTokenChunker(params)
|
|
case ComponentNameTitleChunker:
|
|
return NewTitleChunker(params)
|
|
case ComponentNameGroupTitleChunker:
|
|
return NewGroupTitleChunker(params)
|
|
case ComponentNameHierarchyTitleChunker:
|
|
return NewHierarchyTitleChunker(params)
|
|
case ComponentNameQAChunker:
|
|
return NewQAChunker(params)
|
|
case ComponentNameOneChunker:
|
|
return NewOneChunker(params)
|
|
case ComponentNameTagChunker:
|
|
return NewTagChunker(params)
|
|
case ComponentNameTableChunker:
|
|
return NewTableChunker(params)
|
|
case ComponentNamePresentationChunker:
|
|
return NewPresentationChunker(params)
|
|
default:
|
|
return nil, fmt.Errorf("chunker: unknown component %q", name)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// numeric / list conversion helpers (shared across chunker variants)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func stringListFromAny(in []any) []string {
|
|
out := make([]string, 0, len(in))
|
|
for _, x := range in {
|
|
if s, ok := x.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// regex / split helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// compileDelimPattern compiles a TokenChunker-style []string delimiter list.
|
|
// Only backtick-wrapped entries produce an active pattern (Python
|
|
// token_chunker / rag/nlp/delim list helper). Plain entries are ignored here
|
|
// and used by mergeByTokenSize for sentence-level splitting when no active
|
|
// pattern exists. Canonical single-string parser_config.delimiter parsing
|
|
// lives in ragflow/internal/parser/chunk (ParseDelimiterField).
|
|
func compileDelimPattern(delims []string) *regexp.Regexp {
|
|
return chunk.CompileDelimiterListPattern(delims)
|
|
}
|
|
|
|
// splitKeepingDelim mirrors Python token_chunker._split_text_by_pattern
|
|
// (token_chunker.py:79-94): re.split with a captured delimiter group yields
|
|
// [text, delim, text, delim, ...]; each delimiter is glued to the END of the
|
|
// preceding text segment, so it never surfaces as a standalone chunk. A
|
|
// delimiter with no preceding text (a leading delimiter or one adjacent to
|
|
// another) is dropped together with the empty segment, matching Python's
|
|
// `if not chunk: continue`.
|
|
func splitKeepingDelim(text string, pattern *regexp.Regexp) []string {
|
|
if pattern == nil {
|
|
return []string{text}
|
|
}
|
|
idxs := pattern.FindAllStringIndex(text, -1)
|
|
if len(idxs) == 0 {
|
|
return []string{text}
|
|
}
|
|
var out []string
|
|
cursor := 0
|
|
for _, idx := range idxs {
|
|
start, end := idx[0], idx[1]
|
|
if start == cursor {
|
|
cursor = end
|
|
continue
|
|
}
|
|
out = append(out, text[cursor:end])
|
|
cursor = end
|
|
}
|
|
if cursor < len(text) {
|
|
out = append(out, text[cursor:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// chunk-doc helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// itemText returns the text payload from a JSON-style chunk item,
|
|
// preferring "text", then "content_with_weight".
|
|
func itemText(it schema.ChunkDoc) (string, bool) {
|
|
if it.Text != "" {
|
|
return it.Text, true
|
|
}
|
|
if it.ContentWithWeight != "" {
|
|
return it.ContentWithWeight, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// itemDocType mirrors _build_json_chunks's type derivation.
|
|
func itemDocType(it schema.ChunkDoc) string {
|
|
switch strings.ToLower(strings.TrimSpace(it.DocType)) {
|
|
case "table":
|
|
return "table"
|
|
case "image":
|
|
return "image"
|
|
}
|
|
return "text"
|
|
}
|
|
|
|
// itemTextOrFallback returns the item's preferred text, or "".
|
|
func itemTextOrFallback(it schema.ChunkDoc) string {
|
|
if t, ok := itemText(it); ok {
|
|
return t
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// tokenizeStr is the shared NumTokensFromString wrapper used by
|
|
// Table/Image context attachment. Lives here so we can centrally
|
|
// swizzle the count strategy in one place if needed.
|
|
func tokenizeStr(s string) int { return tokenizer.NumTokensFromString(s) }
|
|
|
|
// toString normalises a chunk-map field to a string. Empty strings
|
|
// for missing fields.
|
|
func toString(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// emptyOutputs returns the canonical no-chunks payload.
|
|
func emptyOutputs() map[string]any {
|
|
return map[string]any{
|
|
"output_format": "chunks",
|
|
"chunks": []map[string]any{},
|
|
}
|
|
}
|
|
|
|
func emptyChunkDocs() []schema.ChunkDoc { return []schema.ChunkDoc{} }
|
|
|
|
// chunkOutputs builds the canonical chunker output (output_format="chunks" +
|
|
// chunks). The Go runtime passes only this explicit output to the next node,
|
|
// so the run-level metadata that downstream components still need (e.g.
|
|
// `name` for Tokenizer title embedding, or tenant_id/kb_id for embedding
|
|
// model resolution) is NOT re-emitted here — it lives in the workflow-wide
|
|
// CanvasState.Globals bag (seeded at pipeline start, published by the File
|
|
// component) and read directly from ctx. See runtime.CanvasState.Globals.
|
|
func chunkOutputs(chunks []schema.ChunkDoc) map[string]any {
|
|
return map[string]any{
|
|
"output_format": "chunks",
|
|
"chunks": schema.ChunkDocsToMaps(chunks),
|
|
}
|
|
}
|
|
|
|
// withName returns a shallow copy of inputs with name set, so a component can
|
|
// guarantee `name` is present on the map it forwards to a decode step without
|
|
// mutating the caller's snapshot.
|
|
func withName(inputs map[string]any, name string) map[string]any {
|
|
cp := make(map[string]any, len(inputs)+1)
|
|
for k, v := range inputs {
|
|
cp[k] = v
|
|
}
|
|
cp["name"] = name
|
|
return cp
|
|
}
|
|
|
|
// cloneInputs returns a shallow copy of m with room for one extra key. Used to
|
|
// inject the Globals-resolved `name` into the decode input without mutating
|
|
// the caller's input snapshot.
|
|
func cloneInputs(m map[string]any) map[string]any {
|
|
if m == nil {
|
|
return map[string]any{}
|
|
}
|
|
cp := make(map[string]any, len(m)+1)
|
|
for k, v := range m {
|
|
cp[k] = v
|
|
}
|
|
return cp
|
|
}
|