内容参考于:图灵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)」组成的列表,第二个元素是替换完占位符的纯文本。设计目的:解决普通文本切分工具会把表格拆碎、破坏表格结构的问题,先把表格 "保护" 起来,切分完成后再还原。
执行 & 调用流程
接收传入的
text和start_table_id参数用正则表达式
<table>.*?</table>配合re.DOTALL(匹配换行)、re.IGNORECASE(忽略大小写),找出文本中所有完整的表格,存入tables列表如果没匹配到任何表格,直接返回空列表 + 原文本,结束执行
初始化三个变量:
table_items:空列表,用来存提取到的表格cleaned:初始值为原文本,用来做替换操作current_id:编号计数器,初始值为传入的start_table_id遍历每一个匹配到的表格:
① 生成格式为 tbl_数字__的唯一占位符 ID
② 在 cleaned 文本中,把当前表格替换成占位符(只替换第一次出现,避免重复内容误替换)
③ 把 (占位符ID, 原始表格内容) 存入 table_items 列表
④ 编号计数器 +1
遍历完成后,返回
(table_items, cleaned)
2. TableConverter 类(表格格式转换工具类,预留未使用)
2.1
get_table_kv_chain静态方法函数说明
- 核心功能:定义一条 LangChain 处理链,调用大模型把 HTML 表格转换成紧凑的「键:值」文本格式,减少 token 占用。
- 入参:无
- 返回值:LangChain 可运行链对象,调用时传入
table_content即可得到转换后的字符串。- 当前状态:主流程未实际调用,属于预留优化方案。原始 HTML 表格大模型也能识别,转换会增加耗时和调用成本,因此默认注释禁用。
执行 & 调用流程
- 定义详细的提示词模板,包含转换规则、正反示例、输入占位符
{table_content}- 用
ChatPromptTemplate.from_template把字符串模板转换成 LangChain 的提示词对象- 用管道符
|按顺序串联:提示词模板 → 大模型 llm → 字符串输出解析器- 返回拼接好的完整链对象
- (外部调用时)调用链的
invoke方法,传入{"table_content": 表格HTML},就会自动填充提示词 → 调用大模型 → 返回 KV 格式的文本
3. MarkdownTableAwareSplitter 类(智能 Markdown 切分类)
3.1
__init__初始化方法函数说明
- 核心功能:类的构造方法,创建实例时自动执行,初始化表格提取工具。
- 入参:无(
self代表实例自身)- 返回值:无
- 作用:提前实例化
TableExtractor,供后续split方法直接调用。执行 & 调用流程
- 创建
MarkdownTableAwareSplitter实例时自动触发- 实例化
TableExtractor类,赋值给实例属性self.table_extractor- 实例创建完成,可调用
split方法执行切分3.2
should_merge静态方法函数说明
核心功能:判断两个文本片段合并后是否超过最大长度限制,决定是否可以合并。
入参:
prev_len: int:已有暂存文本的长度new_len: int:待合并新文本的长度max_len: int:单个文本块允许的最大长度返回值:布尔值,
True表示可以合并,False表示不能合并。设计细节:预留 2 个字符,用来放两段文本之间的换行符
\n\n,避免合并后超长。执行 & 调用流程
- 接收三个长度参数
- 计算公式:
已有长度 + 新长度 + 2(换行预留) <= 最大长度- 返回计算得到的布尔结果
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 检索要求的文档块,同时完整保护表格结构不被切分破坏。
执行 & 调用流程
初始化 4 个全局变量:
final_docs:最终结果列表,存放所有处理完成的文档pending_text:文本暂存区,用来累积合并小片段pending_meta:暂存区对应的元数据global_table_counter:全局表格计数器,保证所有表格 ID 全局唯一遍历输入的每一个初步切分文档:
① 取出文档内容并去除首尾空白,空内容直接跳过
② 复制文档元数据,从元数据的 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
所有文档遍历完成后,把最后剩余的
pending_text(满足最小长度要求)提交为正式文档对最终文档列表做过滤:
- 分类为
Table的文档全部保留- 普通文本太短且不含有效字符的,直接丢弃
返回过滤完成的文档列表
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提问。执行 & 调用流程
- 接收所有传入参数,赋值给实例属性保存
- 初始化 4 个核心属性为
None:vector_db、retriever、chain、documents- 调用
self._load_and_process():执行文档加载、切分、向量化入库- 调用
self._build_retrievers_and_chain():构建混合检索器和完整问答链- 实例初始化完成,可对外提供查询服务
4.2
_load_and_process私有方法函数说明
- 核心功能:负责「文档加载 → 标题切分 → 智能切分 → 向量化存储」的完整预处理流程。
- 入参:无
- 返回值:无
- 作用:把本地 Markdown 文档处理成适合检索的向量数据,持久化到本地向量库,重启不丢失。
执行 & 调用流程
创建
TextLoader文本加载器,指定文件路径和utf-8编码,防止中文乱码调用
loader.load()加载文件内容,得到原始 Document 列表定义标题切分规则:按一级标题
#切分,切分后标题内容存入元数据的Header 1字段创建
MarkdownHeaderTextSplitter切分器,执行初步切分,得到按一级标题拆分的文档列表创建
MarkdownTableAwareSplitter智能切分器,调用split方法做二次切分,结果存入self.documents创建 / 连接 Chroma 向量库,指定集合名、embedding 模型、存储路径
判断向量库目录是否为空:
- 为空(文件数不大于 1):按
batch_size分批把文档添加到向量库,自动生成向量并持久化存储- 不为空:直接复用已有向量库,不重复入库
向量库实例存入
self.vector_db4.3
_build_retrievers_and_chain私有方法函数说明
- 核心功能:构建混合检索器、表格占位符还原步骤、问答生成链,串联成完整的处理链路。
- 入参:无
- 返回值:无
- 作用:把「检索 → 后处理 → 生成」三个环节串成一条可调用的链,简化调用逻辑。
执行 & 调用流程
从向量库创建向量检索器,设置检索 top_k = 6(返回最相关的 6 个结果)
用切分后的原始文档创建 BM25 关键词检索器,设置检索 top_k = 4
创建
EnsembleRetriever集成检索器,按权重0.3(BM25) + 0.7(向量)融合两个检索器的结果,赋值给self.retriever定义内部函数
replace_table_placeholders(专门用来还原表格占位符,详细见下文)用
RunnableLambda把还原函数包装成 LangChain 可运行组件replace_step定义问答提示词模板,包含
{context}(上下文)和{question}(问题)两个占位符用 LCEL 语法串联完整链路:
RunnableMap(并行检索+传递问题) → replace_step(还原表格) → prompt(填充提示词) → llm(生成回答) → StrOutputParser(转字符串)完整链路赋值给
self.chain4.3.1
replace_table_placeholders内部函数函数说明
- 核心功能:把检索到的文本中的
tbl_xx__占位符,替换回真实的表格 HTML 内容。- 入参:
docs(检索到的 Document 列表)- 返回值:拼接完成的上下文字符串
- 作用:把切分时 "保护" 起来的表格还原,让大模型能看到完整的表格内容。
执行 & 调用流程
从向量库中过滤查询所有
category = Table的表格文档遍历表格文档,构建
{table_id: 表格内容}的映射字典table_map遍历每一个检索到的文档:
① 用正则匹配所有 tbl_数字__ 格式的占位符
② 每匹配到一个,就从 table_map 中取出对应表格内容替换掉
③ 替换完成的文本加入上下文列表
把所有上下文片段用双换行拼接成一整段字符串返回
4.4
query公开方法函数说明
- 核心功能:对外提供的问答接口,传入问题直接返回回答。
- 入参:
question: str,用户的问题字符串- 返回值:
str,大模型生成的回答文本- 作用:封装内部复杂的链调用逻辑,对外提供简单易用的接口。
执行 & 调用流程
检查
self.chain是否初始化,未初始化则抛出运行时错误调用 self.chain.invoke({"question": question}),触发整条链路执行:
① RunnableMap:根据问题调用检索器拿到相关文档,同时把问题往下传递
② replace_step:把检索结果中的表格占位符还原,整理成完整上下文
③ 填充提示词模板,把上下文和问题传给大模型
④ 大模型生成回答,解析成纯字符串
返回最终的回答字符串
5. 主程序入口
if __name__ == "__main__"说明
- 作用:脚本直接运行时的示例代码,演示如何使用这套 RAG 系统。
- 特性:当这个文件被其他脚本
import导入时,下面的代码不会执行;只有直接运行本 py 文件时才会执行。执行流程
- 实例化
XiamenDocRAG,传入自定义的文件路径、向量库路径、切分最大长度,自动完成所有初始化工作- 调用
rag.query查询第一个问题「我们公司是叫什么」,打印结果- 调用
rag.query查询第二个问题「公司简介和主要财务指标内容」,打印结果- 调用
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)
