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>
171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
#
|
|
# Copyright 2025 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.
|
|
#
|
|
|
|
"""Canonical parser for the ``parser_config.delimiter`` field.
|
|
|
|
Background
|
|
----------
|
|
The single string field ``parser_config.delimiter`` is consumed by several
|
|
parser implementations depending only on the file extension. Before this
|
|
module existed, six implementations diverged on:
|
|
|
|
* whether bare (non-backtick) characters are honored
|
|
* dedupe behavior
|
|
* sort order
|
|
* CRLF / CR normalization
|
|
* whether ``re.I`` is applied
|
|
* the ``re.escape`` round-trip dance in ``txt_parser``
|
|
|
|
This module owns the canonical parsing rule. All six implementations now
|
|
call :func:`parse_delimiter_field` and :func:`compile_delimiter_pattern`.
|
|
|
|
Parsing rule
|
|
------------
|
|
A "delimiter field" is a string with the following grammar::
|
|
|
|
delimiter_field := token*
|
|
token := backtick_wrapped | bare_char
|
|
backtick_wrapped := "`" bare_char+ "`"
|
|
bare_char := any single Unicode character except "`"
|
|
|
|
Semantics:
|
|
|
|
1. Any character(s) between matching backticks is one multi-character
|
|
delimiter.
|
|
2. Any character outside backticks is its own single-character
|
|
delimiter.
|
|
3. The two are combined, deduplicated, and sorted longest-first so
|
|
``##`` matches before ``#``.
|
|
4. ``\\r\\n`` and standalone ``\\r`` are normalized to ``\\n`` at the
|
|
top of :func:`parse_delimiter_field` so Windows-line-ending
|
|
documents produce identical splits to Unix-line-ending ones.
|
|
5. No ``re.I`` is used. Delimiter matching is case-sensitive.
|
|
|
|
Returns
|
|
-------
|
|
:func:`parse_delimiter_field` returns a ``list[str]`` of raw delimiter
|
|
strings (sorted longest-first, deduplicated, CRLF-normalized).
|
|
:func:`compile_delimiter_pattern` takes that list and returns a regex
|
|
alternation pattern with ``re.escape`` applied, ready for
|
|
``re.split(r"(%s)" % pattern, ...)``.
|
|
|
|
Frontend parity
|
|
---------------
|
|
The web UI preview in ``web/src/utils/delimiter-preview.ts``
|
|
(``parseDelimitersForDisplay``) follows the same parsing rule
|
|
(normalization, dedupe, longest-first order) and applies whitespace
|
|
glyph substitution only for display.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
|
|
# Match a backtick-wrapped token. Case-sensitive on purpose (see #17384).
|
|
_BACKTICK_RE = re.compile(r"`([^`]+)`")
|
|
|
|
|
|
def normalize_text_newlines(text: str) -> str:
|
|
"""Normalize CRLF and standalone CR to LF in source text."""
|
|
if not text:
|
|
return text
|
|
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
|
def has_wrapped_delimiter(s: str) -> bool:
|
|
"""True when the delimiter field contains at least one backtick-wrapped token.
|
|
|
|
Used to decide the historical "custom delimiter" mode that bypasses
|
|
``chunk_token_num``. Separate from whether any delimiter is present
|
|
after parsing (bare single-character delimiters still split).
|
|
"""
|
|
if not s:
|
|
return False
|
|
return _BACKTICK_RE.search(s) is not None
|
|
|
|
|
|
def parse_delimiter_field(s: str) -> list[str]:
|
|
"""Parse the delimiter field into a list of delimiter strings.
|
|
|
|
Returns an empty list for an empty field. Whitespace characters are
|
|
treated as valid single-character delimiters.
|
|
|
|
The output is sorted longest-first and deduplicated while
|
|
preserving the first-occurrence order for equal-length items (the
|
|
sort is stable). CRLF / CR line endings inside the field are
|
|
normalized to LF so a user typing ``"\\r\\n"`` and a user typing
|
|
``"\\n"`` get the same effective delimiter (a single newline).
|
|
"""
|
|
if not s:
|
|
return []
|
|
|
|
# CRLF normalization: \r\n → \n, then standalone \r → \n. We do this
|
|
# before parsing so the parser never sees a \r in either bare-char
|
|
# position or backtick-wrapped content.
|
|
normalized = normalize_text_newlines(s)
|
|
|
|
# Insertion-ordered dedupe so equal-length items keep their first-
|
|
# occurrence order, which is then preserved by the stable sort below.
|
|
delimiters: list[str] = []
|
|
seen: set[str] = set()
|
|
cursor = 0
|
|
for match in _BACKTICK_RE.finditer(normalized):
|
|
start, end = match.span()
|
|
# Bare characters before this backtick-wrapped token.
|
|
for ch in normalized[cursor:start]:
|
|
if ch not in seen:
|
|
seen.add(ch)
|
|
delimiters.append(ch)
|
|
# The backtick-wrapped token (verbatim, except CRLF normalization).
|
|
token = match.group(1)
|
|
if token and token not in seen:
|
|
seen.add(token)
|
|
delimiters.append(token)
|
|
cursor = end
|
|
# Bare characters after the last token (or the whole string if no
|
|
# backticks were present).
|
|
for ch in normalized[cursor:]:
|
|
if ch not in seen:
|
|
seen.add(ch)
|
|
delimiters.append(ch)
|
|
|
|
# Stable sort by length, longest-first.
|
|
result = sorted(delimiters, key=len, reverse=True)
|
|
logging.debug(
|
|
"parse_delimiter_field: parsed %d delimiters with lengths %s",
|
|
len(result),
|
|
[len(delimiter) for delimiter in result],
|
|
)
|
|
return result
|
|
|
|
|
|
def compile_delimiter_pattern(delimiters: list[str]) -> str:
|
|
"""Build an alternation regex pattern from a list of delimiter strings.
|
|
|
|
Each delimiter is ``re.escape``'d so that whitespace and regex
|
|
metacharacters are matched literally. The returned pattern is empty
|
|
when ``delimiters`` is empty.
|
|
|
|
The pattern is intended for use with
|
|
``re.split(r"(%s)" % pattern, text)`` (capture group so delimiters
|
|
appear in the split output) or with
|
|
``re.compile(pattern).finditer(text)``.
|
|
"""
|
|
if not delimiters:
|
|
return ""
|
|
return "|".join(re.escape(d) for d in delimiters if d)
|