【解密源码】 RAGFlow 切分最佳实践- naive parser 语义切块(html & json & doc 篇)

52 阅读6分钟

引言

在 RAGFlow 的多文档解析体系中,HTML、JSON 与 DOC 三类文档具有天然的结构化特性。
相较于 PDF、Markdown 等复杂输入,它们的语义边界更清晰、噪声更少、解析路径更短。

  • HTML 文件具备强标记性,可通过 DOM 递归解析层次结构;
  • JSON / JSONL 文件本身就是结构化数据,适合直接切分为层级化的 chunks;
  • DOC 则属于遗留格式,通过 Tika 提取文本仍然具有较高兼容性。

RAGFlow 在设计 naive parser 的过程中,为这三类结构化文档定制了不同的解析策略:
HTML 以标签为核心进行语义分层;JSON 则以路径遍历实现“语义块切分”;DOC 使用通用解析器快速提取文本内容。
这些策略共同组成了 RAGFlow 的“轻结构化解析引擎”,让模型能高效吸收人类知识的不同表现形式。

省流版

RAGFlow 对 HTML、JSON、DOC 三类文档采用了“轻结构化解析 + 动态切块”策略,实现高效语义块抽取。

HTML 文档
  • 使用 BeautifulSoup 递归解析 DOM 树,删除冗余节点(style/script/comment);
  • 为 block 元素生成唯一 block_id,保留表格与标题结构;
  • 将标题转 Markdown 语法(如 <h1>#),再按 token 数切块。

设计亮点

  • 块级 ID 与标题保留机制:为 HTML 每个语义块生成 block_id,并保留标题等级信息,确保切块后仍能重构原文逻辑。
JSON 文档
  • 自动识别 JSON / JSONL 格式;
  • 通过 _list_to_dict_preprocessing 将嵌套 list 转换为可遍历 dict;
  • 根据 max_chunk_size / min_chunk_size 动态生成层级化 chunk。

设计亮点

  • 深度优先遍历前置处理 JSON 结构:将 JSON 中 value 是列表和字典的复杂场景转换成统一结构,为后续统一处理做基础。
DOC 文档
  • 调用 Apache Tika 提取文本内容;
  • 按换行符快速切分,形成最小语义单元。

手撕版

1. HTML 文档

解析器初始化,调用类实例解析 html 文档

sections = HtmlParser()(filename, binary, chunk_token_num)

1.1 HtmlParser 类

推断正确编码进行解码后,解析 html 文件。

class RAGFlowHtmlParser:
    def __call__(self, fnm, binary=None, chunk_token_num=512):
        if binary:
            encoding = find_codec(binary)
            txt = binary.decode(encoding, errors="ignore")
        else:
            with open(fnm, "r",encoding=get_encoding(fnm)) as f:
                txt = f.read()
        return self.parser_txt(txt, chunk_token_num)
1.1.1 parser_txt

parser_txt 是解析 html 文档的核心方法,主要分 4 个步骤:

1)移除文档干扰信息

# 删除 style 和 script 标签
for style_tag in soup.find_all(["style", "script"]):
    style_tag.decompose()
# 删除 div 中的 script 标签
for div_tag in soup.find_all("div"):
    for script_tag in div_tag.find_all("script"):
        script_tag.decompose()
# 删除内联样式
for tag in soup.find_all(True):
    if 'style' in tag.attrs:
        del tag.attrs['style']
# 删除 HTML 注释
for comment in soup.find_all(string=lambda text: isinstance(text, Comment)):
    comment.extract()

2)递归提取文本

cls.read_text_recursively(soup.body, temp_sections, chunk_token_num=chunk_token_num)

@classmethod
def read_text_recursively(cls, element, parser_result, chunk_token_num=512, parent_name=None, block_id=None):
    if isinstance(element, NavigableString): # 判断是否为文本节点 NavigableString
        ...
        if is_valid_html(content): # 判断是否为有效 html 节点
            soup = BeautifulSoup(content, "html.parser")
            # 递归获取子节点文本内容
            child_info = cls.read_text_recursively(soup, parser_result, chunk_token_num, element.name, block_id)
            parser_result.extend(child_info)
        else:
            # 提取文本
            info = {"content": element.strip(), "tag_name": "inner_text", "metadata": {"block_id": block_id}}
            if parent_name:
                info["tag_name"] = parent_name
            return_info.append(info)
        ...
    elif isinstance(element, Tag): # 判断是否为标签节点
        if str.lower(element.name) == "table": # 表格节点特殊处理
            table_info_list = []
            table_id = str(uuid.uuid1())
            table_list = [html.unescape(str(element))]
            for t in table_list:
                table_info_list.append({"content": t, "tag_name": "table",
                                        "metadata": {"table_id": table_id, "index": table_list.index(t)}})
            return table_info_list
        else: # 保底处理,识别所有 html 标签 BLOCK_TAGS 集合
            block_id = None
            if str.lower(element.name) in BLOCK_TAGS:
                block_id = str(uuid.uuid1())
            for child in element.children:
                child_info = cls.read_text_recursively(child, parser_result, chunk_token_num, element.name,
                                                        block_id)
                parser_result.extend(child_info)
    ...

表格节点特殊处理:保留表格原始样式标签,不会进行文本提取。如:"content": “<table border="1"><tr><th>姓名</th><th>年龄</th></tr><tr><td>张三</td><td>25</td></tr><tr><td>李四</td><td>30</td></tr></table>,

提取文本返回结构中设计 block id 信息,用于后续合并文本

3)文本合并

block_txt_list, table_list = cls.merge_block_text(temp_sections)

@classmethod
def merge_block_text(cls, parser_result):
    ...
    if block_id:
        if title_flag:
            content = f"{TITLE_TAGS[tag_name]} {content}" #标题添加 Markdown 格式
        if lask_block_id != block_id:
            if lask_block_id is not None:
                block_content.append(current_content)
            current_content = content
            lask_block_id = block_id
        else:
            current_content += (" " if current_content else "") + content
    else:
        if tag_name == "table":
            table_info_list.append(item)
        else:
            current_content += (" " if current_content else "" + content)

根据 block id 合并文本,对于表格仍保持原始结构完整。

将标题转换成 Markdown 格式(TITLE_TAGS = {"h1": "#", "h2": "##", "h3": "###", "h4": "#####", "h5": "#####", "h6": "######"})保留语义信息

4)文本切分chunk_token_num 配置切分文本

sections = cls.chunk_block(block_txt_list, chunk_token_num=chunk_token_num)

@classmethod
    def chunk_block(cls, block_txt_list, chunk_token_num=512):
        ...

2. JSON 文档

解析器初始化,调用类实例解析 JSON 文档

sections = JsonParser(chunk_token_num)(binary)

2.1 JsonParser 类

推断正确编码进行解码后,解析 JSON 文件,兼容普通 JSON 格式和 JSONL 格式。

def __call__(self, binary):
    encoding = find_codec(binary)
    txt = binary.decode(encoding, errors="ignore")

    if self.is_jsonl_format(txt):
        sections = self._parse_jsonl(txt)
    else:
        sections = self._parse_json(txt)
    return sections

对于 JSONL 的处理是先将 JSONL 每行转换成 JSON 格式后调用 split_json 处理。

 def _parse_jsonl(self, content: str) -> list[str]:
    lines = content.strip().splitlines()
    all_chunks = []
    for line in lines:
        if not line.strip():
            continue
        try:
            data = json.loads(line)
            chunks = self.split_json(data, convert_lists=True)
    ...
2.1.1 split_json

1)数据结构转换,递归将 JSON 中列表值转换成字典。

preprocessed_data = self._list_to_dict_preprocessing(json_data)

def _list_to_dict_preprocessing(self, data: Any) -> Any:
    if isinstance(data, dict):
        # Process each key-value pair in the dictionary
        return {k: self._list_to_dict_preprocessing(v) for k, v in data.items()}
    elif isinstance(data, list):
        # Convert the list to a dictionary with index-based keys
        return {str(i): self._list_to_dict_preprocessing(item) for i, item in enumerate(data)}
    else:
        # Base case: the item is neither a dict nor a list, so return it unchanged
        return data

递归转换示例:

# 输入
{"a": [1, 2, 3], "b": {"c": ["x", "y"]}}

# 输出
{
  "a": {
    "0": 1,
    "1": 2, 
    "2": 3
  },
  "b": {
    "c": {
      "0": "x",
      "1": "y"
    }
  }
}

2)按照长度配置对 JSON 进行切分,输出 chunk。

chunks = self._json_split(preprocessed_data, None, None)

def _json_split(self, data, current_path: list[str] | None, chunks: list[dict] | None) -> list[dict]:
    """
    Split json into maximum size dictionaries while preserving structure.
    """
    ...
    for key, value in data.items():
        new_path = current_path + [key]
        chunk_size = self._json_size(chunks[-1])
        size = self._json_size({key: value})
        remaining = self.max_chunk_size - chunk_size

        if size < remaining:
            # Add item to current chunk
            self._set_nested_dict(chunks[-1], new_path, value)
        else:
            if chunk_size >= self.min_chunk_size:
                # Chunk is big enough, start a new chunk
                chunks.append({})

            # Iterate
            self._json_split(value, new_path, chunks)
    ...

切分示例:

# 输入
{"a": 1, "b": "hello", "c": {"d": 2, "e": "world"}}

#输出(具体输出需要根据 max_chunk_size 与 min_chunk_size 配置)
[
  {
    "a": 1,
    "b": "hello"
  },
  {
    "c": {
      "d": 2,
      "e": "world"
    }
  }
]

如果多层嵌套数据,使用 new_path = current_path + [key] 来维护嵌套层级,优先对 JSON 数据进行深度遍历

@staticmethod
def _set_nested_dict(d: dict, path: list[str], value: Any) -> None:
    """Set a value in a nested dictionary based on the given path."""
    for key in path[:-1]:
        d = d.setdefault(key, {})
    d[path[-1]] = value

3. DOC 文档

兼容旧版 word .doc 文档解析。使用 python Tika 库解析 doc 文档。

 elif re.search(r"\.doc$", filename, re.IGNORECASE):
    doc_parsed = parser.from_buffer(binary)
    sections = doc_parsed['content'].split('\n')
    sections = [(_, "") for _ in sections if _]

HTML,JSON,DOC 文档经过切分得到 sections 后,还需要进行 sections 后处理,这部分可参考《naive parser 语义切块(pdf 篇)》中 sections 后处理模块中的无图 sections 处理逻辑,经过后处理后得到最终输出的 res。

下期预告

在本期《【解密源码】 RAGFlow 切分最佳实践- naive parser 语义切块(html & json & doc 篇)》中,我们深入剖析了 html|json|doc 文档 RAGFlow 中的完整解析流水线,相较于之前的文档类型的解析方案,因为天生具有结构化信息,这几种文档的解析方案更加简单高效。

至此,naive 模式下所有文档格式的解析方案已经全部拆解完毕,一共是以下 8 中文档类型。

if re.search(r"\.docx$", filename, re.IGNORECASE):
		...
elif re.search(r"\.pdf$", filename, re.IGNORECASE):
		...
elif re.search(r"\.(csv|xlsx?)$", filename, re.IGNORECASE):
		...
elif re.search(r"\.(txt|py|js|java|c|cpp|h|php|go|ts|sh|cs|kt|sql)$", filename, re.IGNORECASE):
		...
elif re.search(r"\.(md|markdown)$", filename, re.IGNORECASE):
		...
elif re.search(r"\.(htm|html)$", filename, re.IGNORECASE):
		...
elif re.search(r"\.(json|jsonl|ldjson)$", filename, re.IGNORECASE):
		...
elif re.search(r"\.doc$", filename, re.IGNORECASE):
		...
else:
	  raise NotImplementedError(
	      "file type not supported yet(pdf, xlsx, doc, docx, txt supported)")

撒花ing🎉🎉🎉