33.RAG进阶(Advanced RAG)-破解 PDF RAG 表格切分难题

内容参考于:图灵AI大模型全栈

下方代码的执行逻辑,代码可以复制给ai,让ai找优化点,然后进行优化

一、整体运行总流程(全局视角)

脚本从运行到得到回答的完整调用链路:

主程序入口 → 实例化 XiamenDocRAG → 执行 __init__ 初始化 → 调用 _load_and_process(加载文档→按标题切分→智能二次切分→存入向量库) → 调用 _build_retrievers_and_chain(构建混合检索器→占位符还原步骤→问答生成链) → 调用 rag.query() → 触发整条链执行(检索文档→还原表格→大模型生成答案) → 返回最终回答


二、逐个函数详细说明 + 调用流程

1. TableExtractor 类(表格提取工具类)

1.1 extract_tables_and_text 静态方法

函数说明

  • 核心功能:从输入文本中匹配所有 HTML 表格标签 <table>...</table>,把表格完整提取出来,并用唯一占位符替换原文中的表格位置。

  • 入参:

    • text: str:原始待处理文本(里面可能包含表格)
    • start_table_id: int = 0:表格编号的起始值,用来生成不重复的占位符,默认从 0 开始
  • 返回值:tuple[List[tuple[str, str]], str],第一个元素是「(占位符 ID, 原始表格 HTML)」组成的列表,第二个元素是替换完占位符的纯文本。

  • 设计目的:解决普通文本切分工具会把表格拆碎、破坏表格结构的问题,先把表格 "保护" 起来,切分完成后再还原。

执行 & 调用流程

  1. 接收传入的 textstart_table_id 参数

  2. 用正则表达式 <table>.*?</table> 配合 re.DOTALL(匹配换行)、re.IGNORECASE(忽略大小写),找出文本中所有完整的表格,存入 tables 列表

  3. 如果没匹配到任何表格,直接返回空列表 + 原文本,结束执行

  4. 初始化三个变量:

    • table_items:空列表,用来存提取到的表格
    • cleaned:初始值为原文本,用来做替换操作
    • current_id:编号计数器,初始值为传入的 start_table_id
  5. 遍历每一个匹配到的表格:

    ① 生成格式为 tbl_数字__的唯一占位符 ID

    ② 在 cleaned 文本中,把当前表格替换成占位符(只替换第一次出现,避免重复内容误替换)

    ③ 把 (占位符ID, 原始表格内容) 存入 table_items 列表

    ④ 编号计数器 +1

  6. 遍历完成后,返回 (table_items, cleaned)


2. TableConverter 类(表格格式转换工具类,预留未使用)

2.1 get_table_kv_chain 静态方法

函数说明

  • 核心功能:定义一条 LangChain 处理链,调用大模型把 HTML 表格转换成紧凑的「键:值」文本格式,减少 token 占用。
  • 入参:无
  • 返回值:LangChain 可运行链对象,调用时传入 table_content 即可得到转换后的字符串。
  • 当前状态:主流程未实际调用,属于预留优化方案。原始 HTML 表格大模型也能识别,转换会增加耗时和调用成本,因此默认注释禁用。

执行 & 调用流程

  1. 定义详细的提示词模板,包含转换规则、正反示例、输入占位符 {table_content}
  2. ChatPromptTemplate.from_template 把字符串模板转换成 LangChain 的提示词对象
  3. 用管道符 | 按顺序串联:提示词模板 → 大模型 llm → 字符串输出解析器
  4. 返回拼接好的完整链对象
  5. (外部调用时)调用链的 invoke 方法,传入 {"table_content": 表格HTML},就会自动填充提示词 → 调用大模型 → 返回 KV 格式的文本

3. MarkdownTableAwareSplitter 类(智能 Markdown 切分类)

3.1 __init__ 初始化方法

函数说明

  • 核心功能:类的构造方法,创建实例时自动执行,初始化表格提取工具。
  • 入参:无(self 代表实例自身)
  • 返回值:无
  • 作用:提前实例化 TableExtractor,供后续 split 方法直接调用。

执行 & 调用流程

  1. 创建 MarkdownTableAwareSplitter 实例时自动触发
  2. 实例化 TableExtractor 类,赋值给实例属性 self.table_extractor
  3. 实例创建完成,可调用 split 方法执行切分
3.2 should_merge 静态方法

函数说明

  • 核心功能:判断两个文本片段合并后是否超过最大长度限制,决定是否可以合并。

  • 入参:

    • prev_len: int:已有暂存文本的长度
    • new_len: int:待合并新文本的长度
    • max_len: int:单个文本块允许的最大长度
  • 返回值:布尔值,True 表示可以合并,False 表示不能合并。

  • 设计细节:预留 2 个字符,用来放两段文本之间的换行符 \n\n,避免合并后超长。

执行 & 调用流程

  1. 接收三个长度参数
  2. 计算公式:已有长度 + 新长度 + 2(换行预留) <= 最大长度
  3. 返回计算得到的布尔结果
3.3 split 核心方法

函数说明

  • 核心功能:对按标题初步切分后的文档做二次智能处理,包含表格提取、文本合并、空块过滤。

  • 入参:

    • documents: List[Document]:按一级标题初步切分后的文档列表
    • max_text_length: int = 1200:单个文本块的最大字符数
    • min_text_length_to_merge: int = 400:跨段落合并的最小长度阈值(预留参数,当前代码未实际判断)
    • min_nonempty_text_len: int = 40:有效文本的最小长度,太短且无意义的块会被丢弃
  • 返回值:List[Document],处理后的文档列表,包含普通文本块和独立的表格块。

  • 核心作用:生成符合 RAG 检索要求的文档块,同时完整保护表格结构不被切分破坏。

执行 & 调用流程

  1. 初始化 4 个全局变量:

    • final_docs:最终结果列表,存放所有处理完成的文档
    • pending_text:文本暂存区,用来累积合并小片段
    • pending_meta:暂存区对应的元数据
    • global_table_counter:全局表格计数器,保证所有表格 ID 全局唯一
  2. 遍历输入的每一个初步切分文档:

    ① 取出文档内容并去除首尾空白,空内容直接跳过

    ② 复制文档元数据,从元数据的 Header 1 字段中提取当前章节标题

    ③ 调用 self.table_extractor.extract_tables_and_text,提取当前文档的所有表格,得到表格列表和占位符替换后的纯文本

    ④ 对纯文本做行级清理:按行拆分 → 过滤空白行 → 去除每行末尾的空白

    ⑤ 逐行累积构建文本块 current_chunk:

    • 尝试把当前行拼到 current_chunk 后面,没超长就继续累积

    • 超长的话,先判断能不能合并到 pending_text:可以就合并;不可以就把 pending_text 提交为正式文档,current_chunk 成为新的暂存内容

    • 当前行作为新 current_chunk的起点,继续循环

      ⑥ 遍历完所有行后,把剩余的 current_chunk 尝试合并到 pending_text

      ⑦ 遍历提取到的所有表格:

    • 先把暂存区的文本提交为正式文档(保证文本和表格的顺序和原文一致)

    • 给表格添加专属元数据(分类标记、表格 ID、所属章节)

    • 把表格创建为独立的 Document,加入最终结果列表

    • 全局表格计数器 +1

  3. 所有文档遍历完成后,把最后剩余的 pending_text(满足最小长度要求)提交为正式文档

  4. 对最终文档列表做过滤:

    • 分类为 Table 的文档全部保留
    • 普通文本太短且不含有效字符的,直接丢弃
  5. 返回过滤完成的文档列表


4. XiamenDocRAG 类(RAG 系统主类)

4.1 __init__ 初始化方法

函数说明

  • 核心功能:类的构造方法,创建实例时自动完成整个 RAG 系统的初始化(文档预处理 + 检索链构建)。

  • 入参(均有默认值):

    • filepath: str:本地 Markdown 文档的路径
    • persist_directory: str:Chroma 向量库的本地存储目录
    • collection_name: str:向量库的集合名称,用来区分不同知识库
    • max_chunk_length: int:文本切分的最大字符长度
    • batch_size: int:向量库入库的批次大小,防止内存溢出
  • 返回值:无

  • 作用:一键完成所有准备工作,实例创建后可以直接调用 query 提问。

执行 & 调用流程

  1. 接收所有传入参数,赋值给实例属性保存
  2. 初始化 4 个核心属性为 Nonevector_dbretrieverchaindocuments
  3. 调用 self._load_and_process():执行文档加载、切分、向量化入库
  4. 调用 self._build_retrievers_and_chain():构建混合检索器和完整问答链
  5. 实例初始化完成,可对外提供查询服务
4.2 _load_and_process 私有方法

函数说明

  • 核心功能:负责「文档加载 → 标题切分 → 智能切分 → 向量化存储」的完整预处理流程。
  • 入参:无
  • 返回值:无
  • 作用:把本地 Markdown 文档处理成适合检索的向量数据,持久化到本地向量库,重启不丢失。

执行 & 调用流程

  1. 创建 TextLoader 文本加载器,指定文件路径和 utf-8 编码,防止中文乱码

  2. 调用 loader.load() 加载文件内容,得到原始 Document 列表

  3. 定义标题切分规则:按一级标题 # 切分,切分后标题内容存入元数据的 Header 1 字段

  4. 创建 MarkdownHeaderTextSplitter 切分器,执行初步切分,得到按一级标题拆分的文档列表

  5. 创建 MarkdownTableAwareSplitter 智能切分器,调用 split 方法做二次切分,结果存入 self.documents

  6. 创建 / 连接 Chroma 向量库,指定集合名、embedding 模型、存储路径

  7. 判断向量库目录是否为空:

    • 为空(文件数不大于 1):按 batch_size 分批把文档添加到向量库,自动生成向量并持久化存储
    • 不为空:直接复用已有向量库,不重复入库
  8. 向量库实例存入 self.vector_db

4.3 _build_retrievers_and_chain 私有方法

函数说明

  • 核心功能:构建混合检索器、表格占位符还原步骤、问答生成链,串联成完整的处理链路。
  • 入参:无
  • 返回值:无
  • 作用:把「检索 → 后处理 → 生成」三个环节串成一条可调用的链,简化调用逻辑。

执行 & 调用流程

  1. 从向量库创建向量检索器,设置检索 top_k = 6(返回最相关的 6 个结果)

  2. 用切分后的原始文档创建 BM25 关键词检索器,设置检索 top_k = 4

  3. 创建 EnsembleRetriever 集成检索器,按权重 0.3(BM25) + 0.7(向量) 融合两个检索器的结果,赋值给 self.retriever

  4. 定义内部函数 replace_table_placeholders(专门用来还原表格占位符,详细见下文)

  5. RunnableLambda 把还原函数包装成 LangChain 可运行组件 replace_step

  6. 定义问答提示词模板,包含 {context}(上下文)和 {question}(问题)两个占位符

  7. 用 LCEL 语法串联完整链路:

    复制代码
    RunnableMap(并行检索+传递问题) → replace_step(还原表格) → prompt(填充提示词) → llm(生成回答) → StrOutputParser(转字符串)
  8. 完整链路赋值给 self.chain

4.3.1 replace_table_placeholders 内部函数

函数说明

  • 核心功能:把检索到的文本中的 tbl_xx__ 占位符,替换回真实的表格 HTML 内容。
  • 入参:docs(检索到的 Document 列表)
  • 返回值:拼接完成的上下文字符串
  • 作用:把切分时 "保护" 起来的表格还原,让大模型能看到完整的表格内容。

执行 & 调用流程

  1. 从向量库中过滤查询所有 category = Table 的表格文档

  2. 遍历表格文档,构建 {table_id: 表格内容} 的映射字典 table_map

  3. 遍历每一个检索到的文档:

    ① 用正则匹配所有 tbl_数字__ 格式的占位符

    ② 每匹配到一个,就从 table_map 中取出对应表格内容替换掉

    ③ 替换完成的文本加入上下文列表

  4. 把所有上下文片段用双换行拼接成一整段字符串返回

4.4 query 公开方法

函数说明

  • 核心功能:对外提供的问答接口,传入问题直接返回回答。
  • 入参:question: str,用户的问题字符串
  • 返回值:str,大模型生成的回答文本
  • 作用:封装内部复杂的链调用逻辑,对外提供简单易用的接口。

执行 & 调用流程

  1. 检查 self.chain 是否初始化,未初始化则抛出运行时错误

  2. 调用 self.chain.invoke({"question": question}),触发整条链路执行:

    ① RunnableMap:根据问题调用检索器拿到相关文档,同时把问题往下传递

    ② replace_step:把检索结果中的表格占位符还原,整理成完整上下文

    ③ 填充提示词模板,把上下文和问题传给大模型

    ④ 大模型生成回答,解析成纯字符串

  3. 返回最终的回答字符串


5. 主程序入口 if __name__ == "__main__"

说明

  • 作用:脚本直接运行时的示例代码,演示如何使用这套 RAG 系统。
  • 特性:当这个文件被其他脚本 import 导入时,下面的代码不会执行;只有直接运行本 py 文件时才会执行。

执行流程

  1. 实例化 XiamenDocRAG,传入自定义的文件路径、向量库路径、切分最大长度,自动完成所有初始化工作
  2. 调用 rag.query 查询第一个问题「我们公司是叫什么」,打印结果
  3. 调用 rag.query 查询第二个问题「公司简介和主要财务指标内容」,打印结果
  4. 调用 rag.query 查询第三个问题「营业收入构有哪些?可以往哪里发展?」,打印结果

下方的代码对于表格的处理并不好,可以进行优化

现在有这样的表格如下

复制代码
姓名 年龄 身高 体重
张三 21  170  64kg
李四 22  180  65kg

把上方的表格转成下方,k和v结构,这样语义识别起来会好一点,这也是现在针对表格数据常用手段,下方的 人员信息 这四个字是对当前表格的一个描述,后面的字段就是表头

复制代码
人员信息(字段:姓名、年龄、身高、体重)
姓名:张三,年龄:21,身高:170,体重:64kg
姓名:李四,年龄:22,身高:180,体重:65kg

除了上方的kv结构大模型真的html的标签table、tr、td标签和json结构处理起来都比较好,但具体要考虑大模型适配什么样的数据

python 复制代码
# 导入操作系统交互模块,用来读写文件、查看目录里的文件列表(比如判断向量库文件夹是否已有数据)
import os
# 导入类型提示工具 List,用来标注「列表」类型的参数/返回值
# 作用:让代码更易读,编辑器能自动做错误提示,不影响代码实际运行
from typing import List
# 导入正则表达式模块,用来匹配、查找、替换文本中的特定格式内容(比如 <table> 标签、占位符)
import re

# 从 langchain 核心库导入 Document 类:LangChain 里的标准文档对象
# 每个 Document 固定包含两部分:
#   - page_content:字符串,存文档的文本内容
#   - metadata:字典,存文档的附加信息(比如来源章节、分类、ID等)
from langchain_core.documents import Document
# 导入聊天提示词模板类:用来定义给大模型的提示词格式,支持用占位符动态填充内容
from langchain_core.prompts import ChatPromptTemplate
# 导入字符串输出解析器:把大模型返回的 ChatMessage 对象转成纯字符串,方便直接使用
from langchain_core.output_parsers import StrOutputParser
# 导入两个 LangChain 可运行组件(LCEL 语法的基础组件):
# - RunnableLambda:把普通 Python 函数包装成链组件,才能和其他组件串联
# - RunnableMap:并行执行多个任务,输出字典格式的结果,每个 key 对应一个任务的结果
from langchain_core.runnables import RunnableLambda, RunnableMap
# 导入 Markdown 标题文本切分器:可以按照 Markdown 的标题层级,把大文档拆成小片段
from langchain_text_splitters import MarkdownHeaderTextSplitter
# 导入文本加载器:专门用来加载本地纯文本/Markdown 文件,自动转成 LangChain 的 Document 对象
from langchain_community.document_loaders import TextLoader
# 导入 BM25 检索器:基于关键词词频匹配的传统检索算法,和向量语义检索形成互补,提升召回准确率
from langchain_community.retrievers import BM25Retriever
# 导入集成检索器:可以把多个检索器的结果按权重融合排序,实现「关键词+语义」的混合检索
from langchain_classic.retrievers import EnsembleRetriever  # 注意:这个包名可能需要确认是否为 langchain_experimental 或其他 fork
# 导入 Chroma 向量数据库:轻量级本地向量库,用来存储文档的向量(embedding),支持语义相似度检索,数据可持久化到本地
from langchain_chroma import Chroma

# 从本地 base_llm 模块导入提前初始化好的两个核心对象:
# - llm:大语言模型实例,用来生成最终回答
# - embeddings_model:向量模型实例,用来把文本转换成向量,存入向量库用于检索
from base_llm import llm, embeddings_model


# 表格提取工具类:专门处理文本里的 HTML 表格标签
# 核心作用:把 <table>...</table> 从原文里整体抽出来,用占位符代替,保证表格不会被文本切分工具拆坏
class TableExtractor:
    """只负责从文本中抽取 <table>...</table> 标签,并用占位符替换,保留原始表格内容"""

    # 静态方法装饰器:标记这个方法是静态方法
    # 特点:不需要实例化类就能直接调用,不用传 self 参数,相当于放在类里管理的普通函数
    @staticmethod
    def extract_tables_and_text(
            text: str,
            start_table_id: int = 0
    ) -> tuple[List[tuple[str, str]], str]:
        """
        功能:从文本中提取所有 <table> 标签内容,并用 tbl_0__、tbl_1__ 这样的占位符替换原文中的表格
        入参说明:
            text : str  输入的原始文本(里面可能包含 <table> 标签)
            start_table_id : int = 0  表格编号的起始数字,用来生成不重复的占位符 ID,默认从 0 开始
        返回值(元组格式,两个元素):
            第一个元素:表格列表,每个元素是 (占位符字符串, 原始表格HTML内容) 的元组
            第二个元素:替换完占位符后的纯文本
        预期数据类型:tuple[List[tuple[str, str]], str]
        """
        # 定义正则表达式匹配规则:匹配从 <table> 开始到 </table> 结束的所有内容
        # 语法说明:
        #   <table> :匹配开头的表格标签
        #   .*?    :非贪婪匹配任意字符(遇到第一个 </table> 就停止)
        #            → 如果不用非贪婪(写成 .*),会从第一个<table>匹配到最后一个</table>,多个表格会被当成一个,就错了
        #   </table>:匹配结尾的表格标签
        #   外面的括号:表示捕获组,re.findall 会只返回括号里匹配到的内容
        table_pattern = r'(<table>.*?</table>)'
        # re.DOTALL:让正则的 . 符号可以匹配换行符(表格是多行的,必须开这个才能匹配到完整表格)
        # re.IGNORECASE:忽略大小写,比如 <TABLE>、<Table> 这种不规范写法也能匹配到
        # re.findall:返回所有匹配到的字符串组成的列表
        tables = re.findall(table_pattern, text, re.DOTALL | re.IGNORECASE)
        
        # 如果文本里没找到任何表格,直接返回空列表和原文本,不做后续处理
        if not tables:
            return [], text
        
        # 用来存储所有提取到的表格,每个元素是 (占位符ID, 原始表格内容) 的元组
        table_items = []
        # 用来保存替换完占位符后的文本,初始值就是原文
        cleaned = text
        # 当前表格的编号,从传入的起始 ID 开始,每处理一个表格就 +1
        current_id = start_table_id

        # 遍历每一个找到的表格,逐个替换成占位符
        for tbl in tables:
            # 生成当前表格的占位符,格式固定为 tbl_数字__,方便后面用正则统一替换回来
            table_id = f"tbl_{current_id}__"
            # 把原文里的这个表格替换成占位符
            # 第三个参数 1 表示只替换第一次出现的位置,避免有重复内容时误替换多个
            cleaned = cleaned.replace(tbl, table_id, 1)  # 只替换第一次出现
            # 把占位符和对应的原始表格存到列表里,后面还原的时候要用
            table_items.append((table_id, tbl))
            # 编号 +1,下一个表格用新的 ID,保证不重复
            current_id += 1

        # 返回提取的表格列表 和 替换完占位符的纯文本
        return table_items, cleaned


# 表格转换工具类:把 HTML 表格转换成更紧凑的键值对(KV)文本格式
# 注意:这个类目前没有在主流程中使用,属于预留的优化方案
class TableConverter:
    """(目前未在主流程中使用)将 HTML 表格转为 key:value 紧凑文本格式"""

    @staticmethod
    def get_table_kv_chain():
        """
        功能:定义一个 LangChain 处理链,调用大模型把 HTML 表格转换成「键:值」格式的紧凑文本
        为什么这么设计:原始 HTML 标签多、占 token 多,转成 KV 格式更省 token,大模型也更容易理解
        返回值:一个可以直接调用的 LangChain 链,传入 table_content 就能得到转换结果
        预期数据类型:LangChain 的 Runnable 链对象,调用后返回字符串
        """
        # 定义给大模型的提示词模板,详细规定了转换规则和示例,保证输出格式稳定可控
        prompt = ChatPromptTemplate.from_template(
            """你是一个数据格式转换助手。
                任务:把下面提供的表格形式数据(通常是两列:键 + 值),转换成紧凑的单行 KV 文本格式。

                转换规则:
                1. 每一行数据输出成一行文本
                2. 格式严格为:键1:值1, 键2:值2, 键3:值3, ... (注意逗号后有一个空格)
                3. 键和值之间用英文冒号 : 连接,不要加空格
                4. 如果一行有多个字段,按表格的列顺序依次写成 key:value 形式,用逗号 + 空格分隔
                5. 第一行如果是表头,则每一行数据都使用这些相同的字段名作为 key
                6. 值中如果本来包含逗号、冒号等特殊字符,保留原样,不要转义
                7. 第一行对表格生成一个20字左右的简介,并介绍字段

                示例输入:
                姓名 年龄 城市
                张三 18 北京
                李四 25 上海

                示例输出:
                人员信息(字段:姓名, 年龄, 城市)
                姓名:张三, 年龄:18, 城市:北京
                姓名:李四, 年龄:25, 城市:上海

                现在请严格按照以上规则转换以下数据:
                {table_content}
                """
        )
        # 用管道符 | 把组件串联成一条处理链,这是 LangChain 的 LCEL 语法
        # 执行规则:数据从左向右流动,左边组件的输出,直接作为右边组件的输入
        # 流程:填充提示词模板 → 调用大模型 → 把结果转成纯字符串
        return prompt | llm | StrOutputParser()


# 智能 Markdown 文本切分类:专门处理带表格的 Markdown 文档
# 核心特点:不会把表格切坏,还会自动把小的文本片段合并成合适大小的块,减少碎片
class MarkdownTableAwareSplitter:
    """智能切分 Markdown,支持识别并特殊处理 <table> 标签"""

    # 初始化方法:创建类实例的时候自动执行
    # self 参数:代表当前创建的类实例本身,给 self.xxx 赋值就是给实例添加属性
    def __init__(self):
        # 实例化一个表格提取器,后面切分的时候用来抽取表格
        self.table_extractor = TableExtractor()

    # 静态方法:判断前后两段文本能不能合并成一个块
    @staticmethod
    def should_merge(prev_len: int, new_len: int, max_len: int) -> bool:
        """
        功能:判断两个文本片段合并后会不会超过最大长度限制
        入参说明:
            prev_len : int  前面已经暂存的文本的长度
            new_len  : int  新的待合并文本的长度
            max_len  : int  单个文本块允许的最大长度
        返回值:布尔值,True=可以合并,False=不能合并
        为什么留 2 个字符:两段文本合并时中间会加两个换行符 \n\n,占 2 个字符,要提前预留出来,避免合并后超长
        """
        return (prev_len + new_len + 2) <= max_len

    # 核心切分方法:对初步切分后的文档做二次智能处理
    def split(
            self,
            documents: List[Document],
            max_text_length: int = 1200,
            min_text_length_to_merge: int = 400,
            min_nonempty_text_len: int = 40,
    ) -> List[Document]:
        """
        核心功能:对已经按标题初步切分好的文档列表进行二次智能切分和优化
        设计目的:
        1. 把零散的小文本段合并成接近 max_text_length 大小的块,减少碎片,提升检索时的上下文完整性
        2. 把 <table>...</table> 完整提取出来,作为独立的文档,保证表格结构不被破坏
        3. 普通文本里用占位符暂时代替表格,保证文本语义连贯
        4. 过滤掉太短、没意义的文本块,节省存储和检索资源(表格全部保留)

        入参说明:
            documents             : List[Document]  输入:已经按一级标题切分好的文档列表
            max_text_length       : int = 1200     单个文本块的最大字符数(目标上限)
            min_text_length_to_merge : int = 400   【预留参数】跨段落合并的最小长度阈值,当前代码暂未使用
                                                  设计初衷:避免过于细碎的合并,减少碎片数量
            min_nonempty_text_len : int = 40       最终保留的文本块最小长度,太短且没实际内容的就丢弃

        返回值:处理完成的文档列表
        预期数据类型:List[Document],包含两类文档:
            1. 普通文本块(已合并、清理过空白)
            2. 独立的表格文档(元数据里 category 标记为 "Table",保留原始 HTML)
        """
        final_docs: List[Document] = []  # 最终结果列表:所有处理好的文档都存在这里,最后返回
        pending_text: str = ""  # 暂存区:正在累积、还没提交的文本,用来合并多个小片段
        pending_meta: dict = {}  # 暂存区对应的元数据:和 pending_text 配套,记录标题等信息
        global_table_counter = 0  # 全局表格计数器,跨所有段落连续编号
        # 为什么要全局连续:如果每个段落都从0开始编号,不同段落里的表格会出现相同的 tbl_0__,替换的时候就会混乱
        # 全局唯一编号能保证每个占位符都只对应一个表格,不会冲突

        # 遍历每一个初步切分后的文档(每个文档通常对应一个一级标题下的内容)
        for doc in documents:
            # 取出文档的文本内容,去掉首尾的空白字符(空格、换行、制表符等)
            content = doc.page_content.strip()
            # 如果内容去掉空白后是空的,直接跳过,不处理空文档
            if not content:
                continue

            # 复制当前文档的元数据,不修改原始文档的元数据(避免影响其他地方使用)
            metadata = doc.metadata.copy()
            # 从元数据里取出当前段落所属的一级标题
            # 这个 "Header 1" 就是前面 MarkdownHeaderTextSplitter 写入的字段名
            # get方法第二个参数是默认值,取不到就显示「未知章节」
            # 作用:给后面的表格打上章节标签,知道表格来自文档的哪个部分
            section_title = metadata.get("Header 1", "未知章节")

            # ========== 步骤1:提取当前文本里的所有表格,替换成占位符 ==========
            # 调用表格提取器,传入当前文本和起始编号
            # 返回值 table_items:提取到的表格列表,每个元素是(表格ID, 原始表格HTML)
            # 返回值 text_only:替换完占位符后的纯文本,里面没有 <table> 标签了,只有 tbl_0__ 这种占位符
            table_items, text_only = self.table_extractor.extract_tables_and_text(
                content, global_table_counter
            )

            # ========== 步骤2:对纯文本做行级清理 ==========
            # splitlines():把文本按换行符拆成一行一行的列表
            # if line.strip():过滤掉完全空白的行(只有空格、换行的行没用,占空间)
            # line.rstrip():只去掉每行末尾的空白,保留开头的缩进(Markdown 的列表、代码块靠缩进区分格式)
            lines = [line.rstrip() for line in text_only.splitlines() if line.strip()]

            current_chunk = ""  # 当前正在拼接的文本块:在当前段落内逐行累积

            # ========== 步骤3:逐行合并,构建符合长度要求的文本块 ==========
            for line in lines:
                # 先尝试把当前行拼到 current_chunk 后面,中间加换行
                # 如果 current_chunk 是空的,就直接用当前行作为开头
                tentative = (current_chunk + "\n" + line).strip() if current_chunk else line

                # 如果拼接后的长度没超过最大限制,就确认拼接,更新 current_chunk
                if len(tentative) <= max_text_length:
                    current_chunk = tentative
                else:
                    # 拼接后超长了,不能继续加了,需要处理当前的 current_chunk
                    # 先看能不能和暂存区的 pending_text 合并
                    if pending_text and self.should_merge(len(pending_text), len(current_chunk), max_text_length):
                        # 可以合并:把 current_chunk 拼到 pending_text 后面,中间加两个换行(段落分隔)
                        pending_text = (pending_text + "\n\n" + current_chunk).strip()
                    else:
                        # 不能合并:先把暂存区的文本提交成正式文档,加入最终结果
                        if pending_text:
                            final_docs.append(Document(
                                page_content=pending_text,
                                metadata=pending_meta
                            ))
                        # 把 current_chunk 放到暂存区,作为新的暂存内容
                        pending_text = current_chunk
                        # 同步更新暂存区的元数据为当前段落的元数据
                        pending_meta = metadata.copy()

                    # 把当前行作为新的 current_chunk 的开头,继续累积
                    current_chunk = line

            # 循环结束后,处理剩下的 current_chunk(最后一段没拼完的内容)
            if current_chunk:
                # 同样先尝试合并到暂存区
                if pending_text and self.should_merge(len(pending_text), len(current_chunk), max_text_length):
                    pending_text = (pending_text + "\n\n" + current_chunk).strip()
                else:
                    # 合并不了就提交暂存区,current_chunk 变成新的暂存
                    if pending_text:
                        final_docs.append(Document(page_content=pending_text, metadata=pending_meta))
                    pending_text = current_chunk
                    pending_meta = metadata.copy()

            # ========== 步骤4:把提取到的表格逐个做成独立文档 ==========
            for table_id, table_content in table_items:
                # 提交表格前,先把暂存区的文本提交了,保证文档顺序和原文完全一致
                # 举个例子:原文顺序是「段落前半部分 → 表格 → 段落后半部分」
                # 如果不先提交前面的文本,表格就会跑到文本前面,顺序就乱了
                # 所以每插入一个表格前,都要先把前面攒的文本先提交,保证顺序正确
                if pending_text:
                    final_docs.append(Document(page_content=pending_text, metadata=pending_meta))
                    # 提交后清空暂存区
                    pending_text = ""
                    pending_meta = {}

                # 给表格准备专属元数据,继承原文档的元数据,再添加表格专属字段
                table_meta = metadata.copy()
                table_meta.update({
                    "category": "Table",  # 分类标记为表格,后面可以单独过滤、单独加权检索
                    "table_id": table_id,  # 表格的占位符 ID,后面还原的时候用来匹配
                    "table_section": section_title,  # 表格所属的章节标题,方便理解上下文
                })

                # 创建表格类型的 Document,加入最终结果
                final_docs.append(Document(
                    page_content=table_content,  # 内容是原始的 HTML 表格
                    # ===== 下面是注释掉的代码:如果想把表格转成 KV 格式再存,就用这行代替上面那行 =====
                    # 代码作用:调用大模型把 HTML 表格转换成紧凑的键值对格式,更省 token
                    # 为什么注释掉:转换需要调用大模型,耗时耗成本;原始 HTML 也能被大模型理解,所以默认不用
                    # page_content=TableConverter.get_table_kv_chain().invoke({"table_content": table_content}),
                    metadata=table_meta
                ))

                # 全局表格计数器 +1,保证下一个表格 ID 不重复
                global_table_counter += 1

        # ========== 步骤5:处理循环结束后最后剩下的暂存文本 ==========
        # 只有文本长度大于等于最小有效长度才保留,太短的没信息量
        if pending_text and len(pending_text.strip()) >= min_nonempty_text_len:
            final_docs.append(Document(page_content=pending_text, metadata=pending_meta))

        # ========== 步骤6:最终过滤,清理无效的短文本 ==========
        filtered = []
        for d in final_docs:
            text = d.page_content.strip()

            # 如果是表格类型的文档,不管长短都保留,表格都有价值
            if d.metadata.get("category") == "Table":
                filtered.append(d)
                continue

            # 普通文本:长度太短 且 不包含任何有意义的字符 → 丢弃
            # re.search(r'\w', text):正则检查文本里有没有单词字符
            # \w 是正则语法,匹配英文字母、数字、下划线,用来判断文本有没有实际内容,不全是标点或空白
            if len(text) < min_nonempty_text_len and not re.search(r'\w', text):
                continue

            # 符合条件的保留下来
            filtered.append(d)

        # 返回过滤后的最终文档列表
        return filtered


# 文档专用 RAG 系统类:把整个 RAG 流程封装成一个类,开箱即用
# 完整流程:加载本地 Markdown 文档 → 智能切分 → 向量化存入向量库 → 构建混合检索器 → 生成回答
class XiamenDocRAG:
    """文档专用的 RAG 封装:加载 → 智能切分 → 向量化 → 混合检索 → 回答"""

    # 初始化方法:创建实例的时候自动执行整个初始化流程
    def __init__(
            self,
            filepath="./xxxx.md",
            persist_directory="./chroma_db",
            collection_name="my_collection",
            max_chunk_length=800,
            batch_size=10,
    ):
        """
        初始化入参说明(都有默认值,不传就用默认的):
            filepath : str  本地 Markdown 文档的路径,默认是当前目录下的xxxx.md
            persist_directory : str  Chroma 向量库的本地存储文件夹路径,向量数据存在这里,重启不丢失
            collection_name : str  向量库的集合名称,相当于数据库里的表名,用来区分不同的知识库
            max_chunk_length : int  文本切分的最大字符长度,默认 800
            batch_size : int  批量存入向量库的批次大小,文档多的时候分批存,防止内存溢出
        """
        # 把传入的参数存为实例属性,后面其他方法里可以用 self.xxx 调用
        self.filepath = filepath
        self.persist_directory = persist_directory
        self.collection_name = collection_name
        self.max_chunk_length = max_chunk_length
        self.batch_size = batch_size

        # 初始化几个核心属性,先设为 None,后面执行方法时赋值
        self.vector_db = None  # 向量数据库实例
        self.retriever = None  # 混合检索器实例
        self.chain = None  # 问答处理链实例
        self.documents = None  # 切分后的原始文档列表,给 BM25 检索器用

        # 执行文档加载和切分处理
        self._load_and_process()
        # 构建检索器和问答链
        self._build_retrievers_and_chain()

    # 私有方法:开头的 _ 表示这是内部方法,不建议外部直接调用
    # 功能:完整的文档处理流程
    def _load_and_process(self):
        """
        功能:完整的文档处理流程
        步骤:加载本地文件 → 按一级标题初步切分 → 自定义智能切分(保护表格)→ 存入 Chroma 向量库
        为什么分两步切分:先按标题切保证语义单元完整,再二次切分控制块大小、保护表格结构
        """
        # 创建文本加载器,指定文件路径和 utf-8 编码,防止中文乱码
        loader = TextLoader(self.filepath, encoding="utf-8")
        # 加载文件内容,得到 Document 对象列表
        # 为什么返回列表:TextLoader 支持批量加载多个文件,单个文件加载后列表里只有1个元素
        docs = loader.load()

        # 定义 Markdown 标题切分规则
        # 规则格式是列表,每个元素是一个元组 (标题标记, 元数据字段名)
        # 第一个元素 "#":匹配 Markdown 的一级标题(就是 # 开头的标题行)
        # 第二个元素 "Header 1":自定义的元数据字段名
        #   → 切分器每切出一个标题下的内容,就会自动把这个标题的文字,存到该文档 metadata 的 "Header 1" 字段里
        #   → 这个名字可以自定义,比如改成 "一级标题" 也可以,只要后面读取时保持一致就行
        headers_to_split_on = [("#", "Header 1")]
        # 创建 Markdown 标题切分器实例,传入上面的切分规则
        markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
        # 执行切分:把一整段文本按一级标题切成多个小文档
        # 为什么用 docs[0]:TextLoader.load() 返回的是列表,单个文件加载后列表里只有1个元素,所以取第0个的文本内容
        split_docs = markdown_splitter.split_text(docs[0].page_content)

        # 创建我们自定义的智能切分器
        splitter = MarkdownTableAwareSplitter()
        # 执行二次切分,处理表格、合并小块,结果存到实例属性里
        self.documents = splitter.split(
            documents=split_docs,
            max_text_length=self.max_chunk_length,
        )

        # 创建/连接 Chroma 向量数据库
        # 如果目录里已有数据,就直接连接已有数据库;如果没有,就创建新的
        self.vector_db = Chroma(
            collection_name=self.collection_name,  # 指定集合名
            embedding_function=embeddings_model,  # 指定用来生成向量的 embedding 模型
            persist_directory=self.persist_directory  # 指定本地存储路径
        )
        # 判断向量库是否已经有数据:如果目录里文件数不大于1,说明是空库,需要写入数据
        # 为什么判断 >1:Chroma 初始化后会生成至少2个文件/文件夹(比如数据库文件、索引文件夹)
        # 这样设计的目的:避免重复向向量库添加文档,导致数据重复
        if not len(os.listdir(self.persist_directory)) > 1:
            # 分批添加文档到向量库
            # 为什么要分批:一次性传入太多文档,embedding模型处理压力大,还可能占满内存
            # 按 batch_size 分批处理,更稳定,也能看到处理进度
            for i in range(0, len(self.documents), self.batch_size):
                # 切片取出当前批次的文档
                batch = self.documents[i:i + self.batch_size]
                # 打印进度,方便看处理到哪了
                print(f'第 {i // self.batch_size + 1} 批次 文件数: {len(batch)}')
                # 把批次文档加入向量库,自动生成向量并持久化存储
                self.vector_db.add_documents(batch)

    # 私有方法:构建检索器和问答链
    def _build_retrievers_and_chain(self):
        """
        功能:构建「混合检索 + 表格占位符还原 + 问答生成」的完整处理链
        为什么用混合检索:向量检索擅长语义匹配,BM25 擅长关键词精确匹配,两者结合效果比单一种好
        """
        # 创建向量检索器:从向量库里做语义相似度检索
        # search_kwargs={"k": 6} 表示检索最相关的 6 个文档
        vector_retriever = self.vector_db.as_retriever(search_kwargs={"k": 6})
        # 构建 BM25 关键词检索器,直接用切分后的原始文档构建索引(存在内存里,不需要向量)
        # 为什么不用向量库构建:BM25 是基于词频统计的传统检索算法,只需要原始文本,不需要向量化
        # k=4 表示检索最相关的 4 个文档
        bm25_retriever = BM25Retriever.from_documents(self.documents, k=4)

        # 集成检索器:把两个检索器的结果融合,按权重打分排序
        # 权重分配:BM25 占 0.3,向量检索占 0.7,语义检索权重更高,因为通常效果更好
        self.retriever = EnsembleRetriever(
            retrievers=[bm25_retriever, vector_retriever],
            weights=[0.3, 0.7]
        )

        # 定义内部函数:把检索到的文本里的表格占位符,替换回真实的表格内容
        def replace_table_placeholders(docs):
            """
            参数:docs - 检索到的 Document 列表
            返回:拼接好的上下文字符串,占位符都替换成了真实表格
            为什么要做这一步:切分的时候把表格换成了占位符,检索到的文本里是占位符,
                            传给大模型之前必须还原,不然大模型看不到表格内容
            """
            # 从向量库里查询所有分类为表格的文档
            # where 参数是 Chroma 的元数据过滤语法:只返回 metadata 里 category 等于 "Table" 的文档
            # 这样就能一次性拿出所有独立存储的表格内容
            table_docs = self.vector_db.get(where={"category": "Table"})
            # 构建「表格ID → 表格内容」的映射字典,后面替换的时候查字典很快
            table_map = {}
            # 用 zip 把元数据列表和文档内容列表一一配对
            # 为什么可以配对:Chroma 的 get 方法返回的两个列表顺序完全对应,第i个元数据就是第i个文档的元数据
            for meta, content in zip(table_docs["metadatas"], table_docs["documents"]):
                tid = meta.get("table_id")
                if tid:
                    table_map[tid] = content

            # 用来存处理后的每个文档内容
            context_parts = []
            # 遍历每个检索到的文档,替换里面的占位符
            for doc in docs:
                text = doc.page_content

                # 定义一个嵌套的替换函数,专门给 re.sub 用
                # 为什么要嵌套写:re.sub 的替换函数只能接收 match 这一个参数
                # 写成嵌套函数,就可以直接访问外面的 table_map 字典,不用额外传参
                def repl(match):
                    # match.group(1):取出正则表达式里第一对括号捕获的内容,也就是 tbl_ 和 __ 中间的数字
                    # 比如匹配到 tbl_3__,group(1) 就拿到字符串 "3"
                    tbl_id = f"tbl_{match.group(1)}__"
                    # 从表格映射字典里找对应的表格内容,找不到就返回缺失提示,避免报错
                    return table_map.get(tbl_id, f"【表格 {tbl_id} 内容缺失】")

                # 用正则替换所有 tbl_数字__ 格式的占位符
                new_text = re.sub(r'tbl_(\d+)__', repl, text)
                # 处理好的文本加入列表
                context_parts.append(new_text)

            # 把所有上下文片段用两个换行拼接成一整段字符串,返回
            return "\n\n".join(context_parts)

        # 把上面的替换函数包装成 LangChain 的可运行组件,这样才能加到链里
        # 输入是包含 retrieved_docs 和 question 的字典,输出是 context 和 question 的字典
        replace_step = RunnableLambda(lambda x: {
            "context": replace_table_placeholders(x["retrieved_docs"]),
            "question": x["question"]
        })

        # 定义问答提示词模板:告诉大模型怎么根据上下文回答问题
        template = """请根据下面给出的上下文或者表格来回答问题:
        {context}

        问题: {question}

        回答要尽量准确、完整,使用 markdown 格式排版。
        如果涉及表格,请保留关键数据,不要省略。
        """
        # 把字符串模板转成 LangChain 的提示词模板对象
        prompt = ChatPromptTemplate.from_template(template)

        # 构建完整的处理链,用 | 连接,数据从左到右流动(LCEL 语法)
        self.chain = (
                # 第一步:RunnableMap 并行执行多个任务,输出一个字典
                # 特点:字典里的每个 key 对应的任务同时执行,互不影响
                # 这里同时做两件事:1. 根据问题检索相关文档;2. 把用户问题原样往下传
                RunnableMap({
                    "retrieved_docs": lambda x: self.retriever.invoke(x["question"]),
                    "question": lambda x: x["question"]
                })
                # 第二步:替换表格占位符,整理成完整上下文
                | replace_step
                # 第三步:把上下文和问题填充到提示词模板里
                | prompt
                # 第四步:调用大模型生成回答
                | llm
                # 第五步:把大模型的输出解析成纯字符串
                | StrOutputParser()
        )

    # 对外公开的查询方法:用户调用这个方法就能得到答案
    def query(self, question: str) -> str:
        """
        功能:执行 RAG 查询,根据用户问题返回回答
        入参:question : str  用户的问题字符串
        返回值:大模型生成的回答文本
        预期数据类型:str
        """
        # 检查 chain 有没有初始化,没初始化就报错,避免调用失败
        if self.chain is None:
            raise RuntimeError("RAG 未初始化完成")
        # 调用处理链,传入问题,返回结果
        return self.chain.invoke({"question": question})


# ========== 使用示例 ==========
# if __name__ == "__main__" 的作用:只有直接运行这个 Python 文件的时候,下面的代码才会执行
# 如果这个文件被其他文件 import 导入,下面的代码不会运行,避免自动执行示例代码
if __name__ == "__main__":
    # 创建 RAG 实例,传入自定义的文件路径、向量库路径、最大块长度
    rag = XiamenDocRAG(
        filepath="./output/xxxxx.pdf/auto/xxxxx.pdf.md",
        persist_directory="./chroma_db",
        max_chunk_length=800,
    )
    # 示例1:查询公司名称
    answer = rag.query("我们公司是叫什么")
    print(answer)
    # 示例2:查询公司简介和主要财务指标
    answer = rag.query("公司简介和主要财务指标内容")
    print(answer)
    # 示例3:查询营业收入构成和发展方向
    answer = rag.query("营业收入构有哪些?可以往哪里发展?")
    print(answer)