RAG工程化 - 解析RAGFlow
-
文件解析与清洗: ragflow/rag/flow/parser/parser.py
-
文本分块: ragflow/rag/flow/chunker/chunker.py
这两个文件是如何工作的:
1. 文件解析与清洗 (ragflow/rag/flow/parser/parser.py)
这个文件负责将不同格式的原始文件(如PDF, Word, Excel等)解析成统一的、可处理的格式。这个过程可以看作是文件的清洗和结构化提取。
工作原理:
-
Parser 类: 这是核心类,它根据文件类型调用不同的解析方法。它支持多种文件格式,包括 PDF, 表格(Excel, CSV), Word, Markdown, 文本, 图片, 甚至音频。
-
按文件类型处理:
-
PDF (_pdf方法): 提供了三种解析方式:
-
deepdoc: 一种深度学习模型,用于精确提取文本、表格和图片,并保留其版面布局信息。
-
plain_text: 将PDF转换成纯文本,忽略版面信息。
-
vlm (Vision Language Model): 使用多模态大模型来理解PDF内容。
-
表格 (_spreadsheet方法): 使用 ExcelParser 将表格文件转换成HTML, JSON或Markdown格式。
-
Word (_word方法): 使用 tika 库来提取Word文档中的文本内容。
-
图片 (_image方法): 支持两种方式:
-
ocr: 使用光学字符识别技术提取图片中的文字。
-
vlm: 使用多模态大模型来描述图片内容。
-
音频 (_audio方法): 将音频文件转换成临时文件,然后调用语音转文本模型进行识别。
-
输出格式: 解析后的内容可以输出为 json (包含文本和位置信息) 或 markdown 等格式,然后传递给下一步的分块模块。
总的来说,parser.py 的作用就是将各种复杂格式的非结构化或半结构化文档,转化成包含文本和必要元数据(如图片、位置信息)的结构化数据,为后续的文本分块做准备。
文本分块 (ragflow/rag/flow/chunker/chunker.py)
当 parser.py 完成文件内容的提取后,chunker.py 负责将这些长文本切分成更小、更易于模型处理的文本块(Chunks)。
工作原理:
-
Chunker 类: 这是分块功能的核心。它定义了多种分块策略,但目前主要实现的是通用的 _general 方法。
-
_general 分块方法:
-
该方法接收来自 Parser 模块的输出 (text, markdown, html, 或 json)。
-
它调用 rag.nlp 模块中的 naive_merge 或 naive_merge_with_images 函数来执行分块。
-
分块过程主要受以下参数控制:
-
chunk_token_size: 每个文本块的目标大小(以token计)。
-
delimiter: 用于切分文本的分隔符,例如换行符 \n。
-
overlapped_percent: 相邻文本块之间的重叠比例,这有助于保持上下文的连续性。
-
输出: 分块后的结果是一个包含多个文本块(chunk)的列表。每个 chunk 是一个字典,至少包含 "text" 字段,可能还包含图片、位置信息等。
-
增强功能:
-
自动生成关键词和问题: 如果配置开启,它还会调用大语言模型(LLM)为每个文本块自动提取关键词 (auto_keywords) 或生成相关问题 (auto_questions),这可以增强后续检索的效果。
总结一下,整个流程是:
-
Parser 首先介入,像一个“解码器”,把各种格式的文件“翻译”成干净的、程序可读的文本和数据。
-
然后 Chunker 像一个“切分器”,把这些长文本按照预设的规则切成大小合适的片段,并能选择性地为这些片段添加额外的元信息(关键词、问题等)。
naive_merge 函数详解 (处理纯文本)
这是最核心的文本分块函数,具体策略如下:
-
初始化: 创建一个空的文本块列表 cks,并为第一个块 cks[0] 赋一个空字符串。
-
遍历文本片段: 逐一处理从 Parser 模块传入的文本片段(sections)。
-
判断片段大小:
-
小片段直接合并: 如果一个文本片段的Token数量小于设定的 chunk_token_num (块大小),那么它会被直接尝试合并到当前的最后一个文本块中。
-
大片段需要切分: 如果一个片段本身就超过了 chunk_token_num,则会先用预设的分隔符(delimiter)将其切分成更小的子片段,然后再对这些子片段进行合并处理。默认的分隔符是 \n。;!?,涵盖了换行和中英文的主要句末标点。
- 合并逻辑 (add_chunk 函数): 这是策略的关键。当一个新片段(或子片段)需要被添加时,会发生以下情况:
-
创建新块: 如果当前的最后一个块为空,或者其Token数量已经超过了设定的阈值 chunk_token_num * (1 - overlapped_percent),系统就会创建一个新的文本块。
-
实现重叠 (Overlap): 在创建新块时,为了保持上下文的连续性,它会从上一个块的尾部取出一部分内容(由 overlapped_percent 控制比例),并将其拼接到新块的开头。
-
追加到当前块: 如果当前块还有足够空间,新的片段会直接追加到这个块的末尾。
- 返回结果: 遍历完所有片段后,函数返回一个由多个文本块组成的列表 cks。
naive_merge_with_images 函数
这个函数是 naive_merge 的一个变体,专门用于处理图文混合的内容。它的策略与前者基本一致,但增加了对图片的处理:
-
当文本片段被合并时,与这些文本相关联的图片也会被相应地合并。
-
如果一个文本块是由多个包含图片的片段合并而成的,这些图片会被垂直拼接成一张大图。
总的来说,ragflow 采用的是一种相当实用且灵活的分块方法:
-
语义边界切分: 它优先使用用户定义的分隔符(默认为句末标点和换行符)来切分文本,这在很大程度上能保证在语义边界上进行切分,避免将一句完整的话从中间断开。
-
大小控制: 严格控制每个分块的Token数量上限,确保其不会超过模型的处理能力。
-
上下文保持: 通过块间重叠(Overlap)的机制,有效地保留了上下文信息,使得每个独立的文本块都包含了部分上一块的结尾和下一块的开头,有助于模型更好地理解语境。
-
图文处理: 能够智能地处理图文混合内容,将相关的文本和图片绑定在同一个块中。
这种策略在保证了分块大小的同时,也尽可能地维持了文本的语义完整性和上下文连续性,是一种在RAG应用中非常常见且有效的分块方法。
Hierarchical_merge
hierarchical_merge 的工作原理:
它采用的是一种基于文档结构的分层分块策略,这天然地创造了父子关系。
- 识别结构: 它使用一组复杂的正则表达式 BULLET_PATTERN 来识别文档中的标题层级。
-
代码体现:
python
# rag/nlp/init.py
BULLET_PATTERN = [[
r"第[零一二三四五六七八九十百0-9]+(分?编|部分)",
r"第[零一二三四五六七八九十百0-9]+章",
r"第[零一二三四五六七八九十百0-9]+节",
# ... and so on for different languages and formats
]]
-
它能识别出像“第一章”、“Section 1.2”、“(一)”这样的标题,并为它们分配不同的层级。
-
构建层级地图: 函数会遍历所有文本片段,根据它们是否匹配上标题模式,为文档的所有内容构建一个层级地图。
-
自下而上聚合: 最关键的一步,它会从最细粒度的内容(普通段落,即最低层级)开始,向上查找它所属的父标题。它会将一个段落和它的直接父标题(甚至爷标题)合并成一个逻辑上的块。
. 如何利用这种父子关系进行检索 (Small-to-Large Retrieval策略)
hierarchical_merge 这样的分块方法,是实现一种称为 “Small-to-Large” 或 “父文档检索 (Parent Document Retriever)” 策略的基础。这种策略能极大地提升RAG的准确性。
流程如下:
- 分块: 你会创建两种类型的块:
-
子块 (Child Chunks): 这些是小的、语义精确的文本块,比如独立的段落。这些块非常适合进行向量相似度搜索,因为它们的主题单一、明确。
-
父块 (Parent Chunks): 这些是大的、包含丰富上下文的块,比如整个章节。它可能包含多个子块。
- 检索 (Retrieval):
-
用户的查询首先与所有子块进行向量相似度匹配。因为子块小而精,所以很容易精确命中与问题最相关的几句话。
-
你得到了最匹配的那个“子块”。
- 追溯 (Expansion):
-
此时,你不是把这个小小的“子块”直接扔给LLM。因为这个子块可能缺少必要的上下文。
-
相反,你通过预先建立的父子关系,找到这个“子块”所属的那个完整的“父块”。
- 生成 (Generation):
- 你将这个内容更完整、上下文更丰富的“父块”作为最终的上下文,提供给LLM来生成答案。
为什么这个策略能提升准确性?
它完美结合了两种块的优点:
-
用“小块”保证检索的精确度:避免了在充满无关信息的大块中稀释语义。
-
用“大块”保证生成的质量:为LLM提供了充分的上下文,让它能做出更全面、准确的回答。
检索阶段 (The Retrieval Phase)
当用户发起一个查询时,神奇的事情发生了。
步骤 1: 在“子块”中进行精确搜索
-
用户的查询(例如:“如何更换电池?”)被转换成一个查询向量。
-
系统在“子块”的向量索引中执行相似度搜索。
-
因为“子块”都是些精准的段落,系统很可能会以高分命中 child_chunk_001,因为它的内容与查询直接相关。
步骤 2: 追溯到“父块”以扩展上下文
-
系统获取到排名靠前的“子块”列表,比如我们命中了 child_chunk_001。
-
系统并不会直接将 "要更换电池..." 这段孤零零的话发给LLM。
-
相反,它会读取这个子块的元数据,找到 parent_chunk_id: "parent_chunk_ABC"。
-
然后,系统使用这个ID去文档库中取出完整的“父块”。
步骤 3: 将完整的上下文提供给LLM
- 最终,被放入Prompt、作为上下文提供给LLM的,是那个内容丰富的“父块”:
"## 3.2 电池维护\n\n警告:在进行任何维护前,请断开电源。\n\n要更换电池,首先请确保设备已关机..."
- LLM现在不仅知道具体步骤,还看到了标题(知道这是关于电池维护的),看到了重要的警告信息。这使得它能够生成一个更安全、更全面、更准确的答案。
总结:hierarchical_merge 的核心价值
hierarchical_merge 通过其对文档层级结构的理解,完美地赋能了“Small-to-Large Retrieval”策略。它解决了传统RAG的一个核心矛盾:
-
用于检索的块(Chunks for Retrieval) 应该小而精,以保证搜索精度。
-
用于生成的块(Chunks for Generation) 应该大而全,以保证答案质量。