目录
一.项目介绍
1. 核心目标
构建一个 企业级 RAG (检索增强生成)问答系统 ,旨在实现:
📄 文档上传 :支持多种格式文档导入。
🔍 智能检索 :精准定位文档相关内容。
💬 准确回答 :基于文档内容生成高质量答案。
⚡ 高并发 :支持多用户同时在线使用。
2. 主要用户画像
👤 用户 1 :企业员工 ( 研发工程师 )
核心需求:
- 快速查找技术文档
- 获取 API 使用示例
- 查阅问题排查指南
😫 痛点:
- 文档数量庞大,难以定位
- 传统关键词搜索不准
- 需要翻阅大量文档才能找到答案
🎧 用户 2 :客服人员 ( 技术支持 )
核心需求:
- 快速响应并回答客户问题
- 实时查找产品信息
- 获取标准解决方案
😫 痛点:
- 无法记忆所有产品细节
- 对新发布产品不了解
- 查资料速度慢,影响客户体验
📊 用户 3 :管理人员 ( 产品经理 )
核心需求:
- 查询业务数据报表
- 深入了解产品功能细节
- 获取市场竞品信息
😫 痛点:
- 文档分散在不同系统
- 关键数据难以提取
- 信息搜集耗时过长
3. MVP 功能范围 ( 必须交付 )
Minimum Viable Product (最小可行产品)
3.1 📤 文档上传
⽤⼾故事:作为⽤⼾,我想上传 PDF ⽂档,以便系统能够理解并
回答相关内容。
✅ 验收标准 :
- 支持上传 PDF 文件
- 界面显示清晰的上传进度条
- 上传成功后给予明确提示
- 支持批量上传多个文件
3.2 🤖 智能问答
⽤⼾故事:作为⽤⼾,我想问关于⽂档的问题,以便获得准确的答案。
✅ 验收标准 :
- 提供友好的问题输入框
- 返回基于文档的准确答案
- 显示引用来源(如页码、段落)
- 严格限制答案基于上传的文档内容(防幻觉)
3.3 🗂 文档管理
⽤⼾故事:作为⽤⼾,我想管理上传的⽂档,以便删除不需要的⽂档。
✅ 验收标准 :
- 列表查看已上传的所有文档
- 支持删除单个或批量文档
- 查看文档的详细元数据(上传时间、大小等)
4. 扩展功能规划

5. 典型应用场景
场景 1 :查询 API 文档
背景 :开发人员需要查找某个 API 的具体用法。
流程:
- 用户上传 API 文档 (PDF)。
- 系统自动解析并建立索引。
- 用户提问:"如何使用用户认证接口?"
- 系统检索相关章节。
- 返回答案:"用户认证接口位于第 12 章,调用方式如下..."
🔍 验证点:
- 答案准确无误
- 包含具体的代码示例
- 标注了准确的页码来源
场景 2 :财务数据查询
背景 :财务人员需要从年报中提取特定数据。
流程:
- 用户上传公司年度财报 (PDF)。
- 系统识别并处理其中的表格数据。
- 用户提问:"2023 年营收是多少?"
- 系统从表格中提取对应单元格数据。
- 返回答案:"2023 年营业收入为 100 亿元。"
🔍 验证点:
- 数据数值准确
- 包含正确的单位
- 引用了具体的表格位置
场景 3 :技术支持辅助
背景 :客服需要快速回答客户关于产品功能的咨询。
流程:
- 客户询问:"产品 X 支持什么功能?"
- 客服在系统中输入该问题。
- 系统检索最新的产品手册。
- 返回答案:"产品 X 支持 A、B、C 三大核心功能..."
- 客服将整理后的信息转述给客户。
🔍 验证点:
- 响应速度快(<3 秒)
- 答案准确且符合最新产品定义
- 语言通俗易懂,适合直接转述
二.技术选型
详见文章顶部的资源。
三.项目搭建
创建目录结构
mkdir rag_project
进入项目根目录后执行,迭代创建项目目录
mkdir -p src/api/routes,src/parsers,src/embeddings,src/vectorstore,src/retrieval,src/llm,src/qa,src/config,tests/integration,data/models
使用 uv 创建虚拟环境
uv venv .venv

四.数据处理模块(解析、清洗、分块、整合...)
1.解析器基类
python
from abc import ABC,abstractmethod
from typing import List,Dict
'''
`dataclass` 是 Python3.7+ 标准库装饰器,**用来快速生成数据类**,
自动帮你写 `__init__`、`__repr__`、`__eq__` 等样板代码,专门用来存数据。
'''
from dataclasses import dataclass
@dataclass
class ParseResult:
'''解析结果统一格式'''
filename: str # 文件名
total_pages: int # 总页数
total_chunks: int # 文本块数
table_count: int # 表格数
chunks: List[Dict] # 文本块列表
raw_text: str # 原始文本
tables: List[Dict] # 表格列表
metadata: Dict # 其他元数据
# 抽象类,所有解析器的基类,定义了所有解析器都必须实现的解析方法
class BaseParser(ABC):
'''
文档解析基类
'''
# 抽象函数,必须实现的解析方法
@abstractmethod
def parse(self,file_path:str) -> ParseResult:
'''
解析文档文件
Args:
file_path:文件路径
Returns:
ParseResult:解析结果
'''
pass
@abstractmethod
def parse_from_bytes(self,file_bytes:bytes,filename:str) -> ParseResult:
'''
从字节流解析文档
Args:
file_bytes:文件字节流
filename:文件名
Returns:
ParseResult:解析结果
'''
pass
@abstractmethod
def supported_extensions(self) -> list[str]:
'''
返回支持的文件扩展名
Returns:
支持的扩展名列表,如['.pdf','.PDF']
'''
pass
2.PDF解析器
顺便推荐两个插件
解决缩进问题:Python Indentation Healer
代码格式美化:Ruff
概述
本模块负责 PDF 文档的解析、提取和结构化处理,是整个 RAG 系统的数据入口。
技术选型

选择 Docling 的原因 :
- 最新一代PDF解析工具,由IBM开发
- 原生支持表格、图表、多列布局
- 输出结构化Markdown格式
- 活跃维护,文档完善
uv add docling
配置pyproject.toml文件:
################################# 文档解析库doclin配置
[tool.uv.sources]
torch = [
# 使用我们下面定义的索引 "pytorch-cu126"
{ index = "pytorch-cu126", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
torchvision = [
{ index = "pytorch-cu126", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
[[tool.uv.index]]
# 定义 PyTorch CUDA 12.6 的专用索引
name = "pytorch-cu126"
url = "https://download.pytorch.org/whl/cu126"
# explicit = true 确保只有我们指定的包从这里获取
explicit = true
[[tool.uv.index]]
# 保留默认的 PyPI 索引,用于安装其他非 PyTorch 的包
name = "pypi"
url = "https://pypi.org/simple"
default = true
解析类:
python
import os
from pathlib import Path
# 国内直连 huggingface.co 通常超时/被墙,默认走 hf-mirror.com 镜像。
# 用 setdefault 保证:若用户已显式设置 HF_ENDPOINT,则不覆盖。
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
# 关闭 Xet 下载协议:hf-mirror.com 不代理 xethub 的 CAS 服务器,
# 大文件走 Xet 会 401,禁用后回退到普通 HTTP 下载即可走镜像。
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
from docling.document_converter import DocumentConverter
from src.parsers.base import BaseParser, ParseResult
# 具体实现的解析器类
class PDFParser(BaseParser):
def __init__(self):
self.converter = DocumentConverter()
def parse(self, file_path: str) -> ParseResult:
"""
解析文档文件
Args:
file_path:文件路径
Returns:
ParseResult:解析结果
"""
# 使用 DocumentConverter 解析 PDF 文件
doc = self.converter.convert(file_path)
# 提取解析结果
raw_text = doc.document.export_to_markdown()
# 提取表格
tables = self._extract_tables(doc)
return ParseResult(
filename=Path(file_path).name,
total_pages=len(doc.pages),
total_chunks=len(raw_text),
table_count=len(tables),
chunks=[],
raw_text=raw_text,
tables=tables,
metadata={'parser':'docling',"file_path": file_path},
)
def parse_from_bytes(self, file_bytes: bytes, filename: str) -> ParseResult:
"""
从字节流解析文档
Args:
file_bytes:文件字节流
filename:文件名
Returns:
ParseResult:解析结果
"""
# 使用 DocumentConverter 从字节流解析 PDF 文件
doc = self.converter.convert_from_bytes(file_bytes)
# 提取解析结果
raw_text = doc.document.export_to_markdown()
# 提取表格
tables = self._extract_tables(doc)
return ParseResult(
filename=filename,
total_pages=len(doc.pages),
total_chunks=len(raw_text),
table_count=len(tables),
chunks=[],
raw_text=raw_text,
tables=tables,
metadata={'parser':'docling',"filename": filename},
)
def supported_extensions(self) -> list[str]:
"""
返回支持的文件扩展名
Returns:
支持的扩展名列表,如['.pdf','.PDF']
"""
return ['.pdf','.PDF']
def _extract_tables(self,doc):
'''
提取表格
'''
tables =[]
# 判断有没有tables
if not hasattr(doc.document,'tables') or not doc.document.tables:
return tables
# 遍历所以表格
for i,table in enumerate(doc.document.tables,1):
try:
# 获取页码
prov = getattr(table,'prov',[])
page_no = prov[0].page_no if prov else None
table_data = {
'table_number':i,
'page':page_no,
'markdown':table.export_to_markdown(doc=doc.document)
}
tables.append(table_data)
except Exception as e:
print(f'警告:无法处理表格:{e}')
continue
return tables
测试:
python
import os
import sys
# 把当前文件往上数 3 层的目录,插到 Python 模块搜索路径 `sys.path` 的最前面,
# 让 Python 优先从这个目录找包 / 模块
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.parsers.pdf_parser import PDFParser
def test_pdf_parse():
parser = PDFParser()
result = parser.parse("./data/WBGAR25_Chinese.pdf")
print(result)
with open('./tests/test_pdf.md','w',encoding='utf-8') as f:
f.write(result.raw_text)
if __name__ == "__main__":
print(os.path.abspath(__file__))
print(os.path.dirname(os.path.abspath(__file__)))
print(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
test_pdf_parse()
# uv run ./tests/test_pdf.py
# python ./tests/test_pdf.py
解析完成之后,会写入一个markdown文件保存。
docling这个库在解析pdf时,如果pdf是图片形式的,图片里面带有文字,那么需要用到文字识别的功能,这时候如果用CPU是比较慢的,注意用GPU版本的torch。如果电脑上有GPU还是要用,要不然解析很多页的(文字不可直接复制的)PDF文件时会非常耗费时间,因为要用RapidOCR(
RapidOCR 不是自己发明的 OCR 算法,它是一个工程化推理 SDK,底层模型是百度飞桨 PaddleOCR 的 PP-OCR 系列(你现在用的是 PP-OCRv6 small),把 PP-OCR 模型转成 ONNX / PyTorch / MNN 等多格式,方便跨平台部署。整个 OCR 流水线分为3 个独立模型串联:
图片 → 文本检测 Det → 文字方向分类 Cls → 文本识别 Rec → 输出文字 + 坐标
)
3.文本清洗
python
import re
from typing import List
class TextCleaner:
'''
文本清洗器
'''
def __init__(self,custom_patterns:List[str] = None):
'''
Args:
custom_patterns (List[str], optional): 自定义正则表达式列表.
'''
# 常见的无意义模式
self.patterns_to_remove = [
r'^\s*\d+\s*$', # 单独的页码
r'^\s*[-=_]+\s*$', # 分隔线
r'^\s*第\s*\d+\s*页\s*$', # 中文页码标记
r'^\s*Page\s*\d+\s*$', # 英文页码
r'^\s*\d+\s*/\s*\d+\s*$', # 页码格式:1/10
]
# 添加自定义模式
if custom_patterns:
self.patterns_to_remove.extend(custom_patterns)
def clean(self,text:str) -> str:
'''
清洗文版,去除无意义内容
Args:
text (str): 文本
Returns:
str: 清洗后的文本
'''
lines = text.split('\n')
cleaned_lines = []
for line in lines:
# 去除空白行
line = line.strip()
if not line:
continue
# 匹配自定义模式
for pattern in self.patterns_to_remove:
if re.match(pattern,line):
break
else:
cleaned_lines.append(line)
return '\n'.join(cleaned_lines)
def normalize_whitespace(self,text:str) -> str:
'''
去除多余的空白字符
- 合并多个空格
- 合并多个换行
- 去除行首行尾的空白字符
Args:
text (str): 输入文本
Returns:
str: 处理后的文本
'''
# 合并多个换行为一个空行,注意'\n\n'不是代表两个空行,而是视觉上的一个空行
'''
print("abc"+"\n\n"+"def")
输出:
abc
def
'''
text = re.sub(r'\s+', ' ', text)
# 合并多个换行
text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text)
return text.strip()
def remove_extra_chars(self,text:str) -> str:
'''
去除多余的字符
Args:
text (str): 输入文本
Returns:
str: 处理后的文本
'''
# 去除多余的字符
text = re.sub(r'[^\u4e00-\u9fa5]', '', text)
return text
def full_clean(self,text:str) -> str:
'''
文本清理
Args:
text (str): 输入文本
Returns:
str: 处理后的文本
'''
text = self.clean(text)
text = self.normalize_whitespace(text)
text = self.remove_extra_chars(text)
return text
测试:
python
"""测试 TextCleaner 文本清理器"""
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.parsers.cleaner import TextCleaner
def test_clean():
"""测试基本文本清理功能"""
print("=== 测试基本文本清理 ===")
cleaner = TextCleaner()
# 测试文本(包含页码、分隔线等无意义内容)
test_text = """这是第一段有用的内容。
1
这是第二段内容。
___
这是第三段内容。
第 5 页
这是第四段内容。
Page 10
3
这是第五段内容。
1/10
这是最后一段。"""
result = cleaner.clean(test_text)
print("原始文本:")
print(test_text)
print("\n清理后文本:")
print(result)
print()
# 验证结果
assert "这是第一段有用的内容" in result
assert "这是第二段内容" in result
assert "这是第三段内容" in result
assert "这是第四段内容" in result
assert "这是第五段内容" in result
assert "这是最后一段" in result
# 验证页码和分隔线被移除
assert "\n1\n" not in result
assert "___" not in result
assert "第 5 页" not in result
assert "Page 10" not in result
assert "1/10" not in result
print("✓ 基本清理测试通过")
def test_normalize_whitespace():
"""测试空白字符规范化"""
print("\n=== 测试空白字符规范化 ===")
cleaner = TextCleaner()
test_text = "这是 多个 空格\n\n\n\n这是多个换行\n 这是行首行尾空白 "
result = cleaner.normalize_whitespace(test_text)
print("原始文本:")
print(repr(test_text))
print("\n规范化后文本:")
print(repr(result))
print()
# 验证多个空格被合并
assert "这是 多个 空格" not in result
assert "这是 多个 空格" in result
# 验证多个换行被合并
assert "\n\n\n\n" not in result
print("✓ 空白字符规范化测试通过")
def test_remove_extra_chars():
"""测试移除特殊字符"""
print("\n=== 测试移除特殊字符 ===")
cleaner = TextCleaner()
# 包含零宽字符的文本
test_text = "正常文本\u200b\u200c\u200d\ufeff更多内容"
result = cleaner.remove_extra_chars(test_text)
print("原始文本:")
print(repr(test_text))
print("\n移除后文本:")
print(repr(result))
print()
# 验证零宽字符被移除
assert "\u200b" not in result
assert "\u200c" not in result
assert "\u200d" not in result
assert "\ufeff" not in result
assert "正常文本更多内容" in result
print("✓ 移除特殊字符测试通过")
def test_full_clean():
"""测试完整清洗流程"""
print("\n=== 测试完整清洗流程 ===")
cleaner = TextCleaner()
test_text = """这是第一段 有用内容。
2
这是第二段 内容。
___
这是第三段内容\u200b\u200b\u200b。
第 10 页
这是最后一段。 """
result = cleaner.full_clean(test_text)
print("原始文本:")
print(test_text)
print("\n完整清洗后:")
print(result)
print()
# 验证所有清理步骤都生效
assert "2" not in result or "这是第二段" in result # 页码被移除但内容保留
assert "___" not in result # 分隔线被移除
assert "第 10 页" not in result # 中文页码被移除
assert "\u200b" not in result # 零宽字符被移除
assert " " not in result # 多余空格被合并
print("✓ 完整清洗流程测试通过")
def test_custom_patterns():
"""测试自定义清理模式"""
print("\n=== 测试自定义清理模式 ===")
# 添加自定义模式:移除包含 "TODO:" 的行
custom_patterns = [r'^\s*TODO:.*$']
cleaner = TextCleaner(custom_patterns=custom_patterns)
test_text = """这是有用的内容。
TODO: 这是一个待办事项
这是另一段有用内容。"""
result = cleaner.clean(test_text)
print("原始文本:")
print(test_text)
print("\n清理后文本:")
print(result)
print()
# 验证自定义模式生效
assert "这是有用的内容" in result
assert "这是另一段有用内容" in result
assert "TODO: 这是一个待办事项" not in result
print("✓ 自定义清理模式测试通过")
def test_empty_text():
"""测试空文本处理"""
print("\n=== 测试空文本处理 ===")
cleaner = TextCleaner()
result = cleaner.clean("")
assert result == ""
result = cleaner.full_clean(" \n\n \n ")
assert result == "" or result.strip() == ""
print("✓ 空文本处理测试通过")
def run_all_tests():
"""运行所有测试"""
print("=" * 50)
print("开始运行 TextCleaner 测试")
print("=" * 50)
try:
test_clean()
test_normalize_whitespace()
test_remove_extra_chars()
test_full_clean()
test_custom_patterns()
test_empty_text()
print("\n" + "=" * 50)
print("✅ 所有测试通过!")
print("=" * 50)
except AssertionError as e:
print(f"\n❌ 测试失败: {e}")
raise
except Exception as e:
print(f"\n❌ 发生错误: {e}")
raise
if __name__ == "__main__":
run_all_tests()
4.表格处理
存在多个表格,多个表格合并的情况
提取表格中某一部分的内容或按指定格式输出
面临上述情况时,可能需要对表格进行处理,所以需要单独写一个表格处理模块。
之前的小节将pdf解析为了markdown,本节就将markdown解析,下面是markdown的样式:

下面代码里 合并表格那里说的是表格跨页的情况。
python
from typing import Dict, List, Optional
import pandas as pd
class TableProcessor:
'''表格解析器'''
# Optional[pd.DataFrame] 代表返回值可能是DataFrame也可能是None
def markdown_to_dataframe(self,table_markdown:str) -> Optional[pd.DataFrame]:
'''
将markdown表格转换为DataFrame
参数:
table_markdown: markdown表格字符串
返回:
pd.DataFrame: 转换后的DataFrame
'''
# 先用split('\n')将表格按行分割,然后判断是否有值,如果有,加入到列表里
lines = [line.strip() for line in table_markdown.strip().split('\n') if line.strip()]
#只有一个表头
if len(lines) < 2:
return None
# 解析表头
headers = [cell.strip() for cell in lines[0].split('|')[1:-1]]
# 跳过分割线
data_lines = lines[2:] if len(lines)>2 else []
# 解析数据行
data = []
for line in data_lines:
# 存在|说明是多列,不存在|说明缺列,数据缺失,跳过该行
if '|' in line:
cells = [cell.strip() for cell in line.split('|')[1:-1]]
if len(cells) == len(headers):
data.append(cells)
return pd.DataFrame(data,columns=headers)
def dataframe_to_text(self,df:pd.DataFrame) -> str:
'''
将DataFrame转换为markdown表格
参数:
df: DataFrame对象
返回:
str: markdown表格字符串
'''
if df.empty:
return ''
# 生成文本数据
parts = ['表格内容:']
# 列名
parts.append('|'.join(df.columns))
for _,row in df.iterrows():
parts.append('|'.join(row))
# 转为字符串
return '\n'.join(parts)
def table_to_text(self,table_markdown:str) -> str:
'''
将markdown表格转换为纯文本
参数:
table_markdown: markdown表格字符串
返回:
str: 纯文本字符串
'''
df = self.markdown_to_dataframe(table_markdown)
if df is None:
return ''
return self.dataframe_to_text(df)
def merge_tables(self,tables:List[Dict]) -> List[Dict]:
'''
合并跨页的拆分表格
参数:
tables: 表格数据,page,cols,markdown,row
返回:
str: 合并后的markdown表格字符串
'''
# 只有一个表格,无需合并,直接返回
if len(tables) <=1:
return tables
# 最终合并完毕的表格列表(里面的每一个表格都是完整的不需要再合并的了)
# 初始化,第一个表格直接加入
merged = [tables[0]]
for i in range(1,len(tables)):
current = tables[i]
# 合并完毕序列里的最后一个表格
last = merged[-1]
# 判断是否需要合并
should_merge = (
# 列数一一致且当前表格页码是上一个表格页码的下一页
current.get('cols') == last.get('cols') and
current.get('page') == last.get('page')+1
)
# 需要合并
if should_merge:
# 拼接当前表格的markdown内容到上一个表格的markdown内容
last['markdown'] += '\n' + current['markdown'] # 合并markdown
if 'row' in last and 'row' in current:
# 更新总行数
last['row'] += current['row'] # 合并行号
# 不需要合并,直接加入到最终列表
else:
merged.append(current)
return merged
# 把两列的 Markdown 表格,解析成 Python 字典 `{key: value}`。
def extrace_key_value(self,table_text:str) -> Dict[str,str]:
'''
从markdown表格中提取键值对
参数:
table_markdown: markdown表格字符串
返回:
Dict[str,str]: 键值对字典
'''
df = self.markdown_to_dataframe(table_text)
if df is None or len(df.columns)<2:
return {}
result ={}
for _ ,row in df.iterrows():
key = str(row.iloc[0].strip())
value = str(row.iloc[1].strip())
if key:
result[key] = value
return result
测试:
测试代码用阿里的通义灵码写的,确实蛮好用的,安利。

5.智能分块(按段落、句子、字符分块)
回顾一下RAG的典型流程包括三步:
- 检索( Retrieval ):根据用户问题,从知识库中找出最相关的片段;
- 增强( Augmentation ):将检索结果作为上下文注入 Prompt;
- 生成( Generation ):LLM 基于上下文生成准确、可溯源的回答。
分块的原因:
用户在提问时,模型从现有的知识库(本文解析的pdf文件)查询相似度最大的片段时,如果pdf文件页数很多,那么会返回很多不相关的信息,但我们只需要返回相关联的文本片段,与用户的提问整合注入到Prompt即可。综上所述,需要对pdf进行分块,到时候直接返回相关的块即可。
注:在实际开发之中一般分块之前会统计一下知识库内容的基础情况,如果看看段落的最大/最小长度,句子的最大/最小长度等,以便不盲目设置分块的最大和最小长度。
具体看下面的代码:
python
import re
from typing import Dict, List, Optional
'''
这是**类属性(Class Attribute)**,写在类里面、函数外面。
- 属于**类本身**,不是属于某个实例对象
- 作用:充当常量枚举,用来限定可选的策略字符串
- 访问方式:`ChunkStrategy.PARAGRAPH`,**不需要创建实例**就能直接用
- 所有实例共享这一组变量,不会每个对象单独拷贝一份
'''
class ChunkStrategy:
"""分块策略枚举"""
PARAGRAPH = "paragraph" # 按段落分块
SENTENCE = "sentence" # 按句子分块
CHARACTER = "character" # 按字符数分块
SEMANTIC = "semantic" # 语义分块(阶段三,使用模型)
'''
`self.xxx` 是**实例属性(Instance Attribute)**,写在 `__init__` 构造函数内,赋值给 `self`。
- 属于**每一个实例对象**
- 作用:保存这个**单独分块器实例**的配置参数
- 访问方式:必须先创建对象 `chunker = TextChunker()`,再 `chunker.strategy`
- 每个实例都有独立一份,修改一个实例的属性不会影响其他实例
'''
class TextChunker:
"""文本分块器"""
def __init__(
self,
strategy: str = ChunkStrategy.PARAGRAPH,
max_chunk_size: int = 1000,
min_chunk_size: int = 100,
# 两个块的重叠长度
chunk_overlap: int = 200,
):
"""
初始化分块器
Args:
strategy: 分块策略
max_chunk_size: 最大块大小(字符数)
min_chunk_size: 最小块大小
chunk_overlap: 块之间重叠字符数
"""
self.strategy = strategy
self.max_chunk_size = max_chunk_size
self.chunk_overlap = chunk_overlap
self.min_chunk_size = min_chunk_size
def split(self, text: str, tables: Optional[List[Dict]] = None) -> List[Dict]:
'''
通过策略对文本进行分块,文本和表格分开处理
'''
if self.strategy == ChunkStrategy.PARAGRAPH:
chunks = self._split_by_paragraph(text)
elif self.strategy == ChunkStrategy.SENTENCE:
chunks = self._split_by_sentence(text)
elif self.strategy == ChunkStrategy.CHARACTER:
chunks = self._split_by_character(text)
else:
# 写个else,防止传入未知策略导致报错,默认按段落分块
chunks = self._split_by_paragraph(text)
# 添加表格信息
if tables:
chunks = self._attach_tables(chunks, tables)
return chunks
def _split_by_paragraph(self, text: str) -> List[Dict]:
'''
按段落分块
1. 用双换行符切出段落(split('\\n\\n')),逐段累加到 current_chunk。
2. 当「当前块 + 本段」超过 max_chunk_size 时:
- 若当前块已达 min_chunk_size,先将其作为一个块输出。
- 若本段自身超过 max_chunk_size,则用 _split_long_paragraph 再细分为多个子块。
- 否则只把本段放入新块,并从当前块尾部保留 chunk_overlap 长度的重叠,实现块间上下文衔接。
3. 未超长时,继续把本段追加到 current_chunk(段之间用 \\n\\n 连接)。
4. 循环结束后,若还有未输出的 current_chunk 且长度 >= min_chunk_size,作为最后一块输出。
'''
chunks = []
# 之前在文本清洗阶段已经将多个空行换为一个空行了,所以这里用一个空行分隔
paragraphs = text.split("\n\n")
current_chunk = ""
# 当前块索引,代表第几个块
chunk_index = 0
for para in paragraphs:
# 跳过空行
para = para.strip()
if not para:
continue
# 检查拼接后的长度
new_chunk_length = len(current_chunk) + len(para) + (2 if current_chunk else 0) # +2 for "\n\n"
# 当前块 + 新块 > 最大块大小,无法直接完整拼接
if new_chunk_length > self.max_chunk_size:
# 如果当前块长度满足最小块长度,则添加到结果中
if len(current_chunk) >= self.min_chunk_size:
# 确保添加的块不超过最大长度(如果超过则截断)
chunk_content = current_chunk.strip()
if len(chunk_content) > self.max_chunk_size:
chunk_content = chunk_content[:self.max_chunk_size]
chunks.append({
"content": chunk_content,
"index": chunk_index,
"length": len(chunk_content),
"type": "text"
})
chunk_index += 1 # 块索引加1
# 保留当前块尾部的重叠内容,用于上下文衔接
overlap_start = max(0, len(current_chunk) - self.chunk_overlap)
current_chunk = current_chunk[overlap_start:]
else:
# 当前块太短,不值得保留重叠,直接清空
current_chunk = ""
# 处理新块逻辑
if len(para) > self.max_chunk_size:
# 如果新块长度超过最大块长度,需要进行块的分割
sub_chunks = self._split_long_paragraph(para, chunk_index)
# 将子块添加到结果中
chunks.extend(sub_chunks)
chunk_index += len(sub_chunks)
current_chunk = ""
else:
# 新块内容没有超过最大块长度,拼接到当前块中
current_chunk = current_chunk + '\n\n' + para if current_chunk else para
else:
# 当前块 + 新块 ≤ 最大块大小,直接拼接
current_chunk = current_chunk + "\n\n" + para if current_chunk else para
# 收尾:剩余内容达到最小块大小时作为最后一块
if current_chunk and len(current_chunk) >= self.min_chunk_size:
# 确保最后一块也不超过最大长度
chunk_content = current_chunk.strip()
if len(chunk_content) > self.max_chunk_size:
chunk_content = chunk_content[:self.max_chunk_size]
chunks.append({
"content": chunk_content,
"index": chunk_index,
"length": len(chunk_content),
"type": "text"
})
return chunks
def _split_long_paragraph(self, text: str, start_index: int) -> List[Dict]:
"""分割过长的段落:先按句子切,超长句子再按字符切,并控制块大小与重叠。"""
# 用中英文句末标点+可选空白切分,保留分隔符(括号分组)
sentences = re.split(r'([。!?.!?]\s*)', text)
# 奇偶配对:前半是"标点前文本",后半是"标点+空白",拼成完整句子列表
sentences = [''.join(pair) for pair in zip(sentences[::2], sentences[1::2])]
chunks = [] # 最终块列表
current_chunk = "" # 当前累积的块内容
chunk_index = start_index # 块序号,与调用方已有块衔接
for sent in sentences:
# 当前块加上本句会超限 → 先收尾当前块,再处理本句
if len(current_chunk) + len(sent) > self.max_chunk_size:
if current_chunk:
chunks.append({
"content": current_chunk.strip(),
"index": chunk_index,
"length": len(current_chunk),
"type": "text"
})
chunk_index += 1
# 本句单独就超长 → 按固定步长滑动窗口按字符切
if len(sent) > self.max_chunk_size:
# 步长 = 块大小 - 重叠,使相邻子块有 overlap
for i in range(0, len(sent), self.max_chunk_size - self.chunk_overlap):
sub_chunk = sent[i:i + self.max_chunk_size]
if len(sub_chunk) >= self.min_chunk_size:
chunks.append({
"content": sub_chunk,
"index": chunk_index,
"length": len(sub_chunk),
"type": "text"
})
chunk_index += 1
current_chunk = "" # 长句已全部切成子块,清空
else:
current_chunk = sent # 本句不超长,作为下一块的首句
else:
current_chunk += sent # 未超限,直接追加本句
# 收尾:剩余内容达到最小块大小时作为最后一块
if current_chunk and len(current_chunk) >= self.min_chunk_size:
chunks.append({
"content": current_chunk.strip(),
"index": chunk_index,
"length": len(current_chunk),
"type": "text"
})
return chunks
def _split_by_sentence(self, text: str) -> List[Dict]:
"""按句子分割。
整体逻辑:先用正则按中英文句末标点切分文本并保留标点,得到"句子+标点"列表;
然后顺序遍历句子,在不超过 max_chunk_size 的前提下把多句合并成一块;
若加上当前句会超长,则先输出当前块(若达到 min_chunk_size),再把当前句作为新块起点;
最后若还有剩余内容且达到最小块大小,作为最后一块输出。
"""
# 按句末标点(。!?.!?)及其后可选空白切分,括号分组使标点被保留在结果中
# .!?是英文标点 。!?是中文标点
# 返回的结果格式是['句子1', '。', '句子2', '!', '句子3', '?', ...]
sentences = re.split(r'([。!?.!?]\s*)', text)
# 奇偶配对:split 结果中偶数位是"标点前文本",奇数位是"标点+空白",拼成完整句子列表
# 2代表步长,一个是偶数索引(0, 2, 4, ...)是标点前的文本,奇数索引(1, 3, 5, ...)是标点符号+空白
'''
print(list(zip(sentences[::2], sentences[1::2])))
输出示例:
[('hello', '。'), ('world', '!'), ('this', '.'), ('is', '?'), ('a', '!'), ('test', '。')]
sentences输出示例:
['hello。', 'world!', 'this.', 'is?', 'a!', 'test。']
'''
sentences = [''.join(pair) for pair in zip(sentences[::2], sentences[1::2])]
chunks = [] # 最终输出的块列表
current_chunk = "" # 当前正在累积的一块内容
chunk_index = 0 # 当前块的序号
for sent in sentences:
sent = sent.strip() # 去掉句子首尾空白
if not sent: # 空句跳过
continue
'''
用特值法举两个例子,就能明白,下面的写法确实能保证当前块的长度不超过最大长度
'''
# 若当前块加上本句会超过最大块大小,需要先收尾再开新块
if len(current_chunk) + len(sent) > self.max_chunk_size:
# 当前块已达到最小块大小时,输出为一块
if len(current_chunk) >= self.min_chunk_size:
chunks.append({
"content": current_chunk.strip(),
"index": chunk_index,
"length": len(current_chunk),
"type": "text"
})
chunk_index += 1
# 本句作为新块的起点(可能单句就超长,后续会在别处再切)
current_chunk = sent
else:
# 未超限:拼到当前块,非首句时前加空格
current_chunk += " " + sent if current_chunk else sent
# 收尾:剩余内容达到最小块大小时作为最后一块
if current_chunk and len(current_chunk) >= self.min_chunk_size:
chunks.append({
"content": current_chunk.strip(),
"index": chunk_index,
"length": len(current_chunk),
"type": "text"
})
return chunks
def _split_by_character(self, text: str) -> List[Dict]:
"""按字符数分割。
整体逻辑:从文本开头起,以 (max_chunk_size - chunk_overlap) 为步长滑动截取,
每次取长度为 max_chunk_size 的子串作为一块;步长小于块长从而实现块与块之间的重叠;
仅当块去掉首尾空白后长度不小于 min_chunk_size 时才加入结果,避免过短碎片。
"""
chunks = [] # 最终输出的块列表
chunk_index = 0 # 当前块的序号
# 按固定步长滑动:步长 = 块长 - 重叠,使相邻块有 chunk_overlap 的重叠
for i in range(0, len(text), self.max_chunk_size - self.chunk_overlap):
chunk = text[i:i + self.max_chunk_size] # 截取本段,最多 max_chunk_size 字符
# 仅当去掉空白后仍达到最小块大小时才计入
if len(chunk.strip()) >= self.min_chunk_size:
chunks.append({
"content": chunk.strip(),
"index": chunk_index,
"length": len(chunk),
"type": "text"
})
chunk_index += 1
return chunks
def _attach_tables(self,chunks: List[Dict], tables: List[Dict]) -> List[Dict]:
'''
将表格作为单独的块添加到分块列表中
'''
chunk_index = len(chunks)
for table in tables:
# 在pdf_parser.py中,_extract_tables函数返回的是:List[Dict],
# 每个字典包含table_number, page, markdown等信息,markdown是表格的markdown格式
table_text = f'表格 (第{table.get("page","?")}页):\n {table.get("markdown", "")}'
chunks.append({
# 表格内容
"content": table_text,
# 第几个块
"index": chunk_index,
# 当前表格长度
"length": len(table_text),
# 表格所在的页码
"type": "table",
"page":table.get("page","?"),
# pdf_parser.py中_extract_tables函数中table_data
"table_data":table
})
chunk_index += 1
return chunks
和之前一样,让ai写个测试代码测试一下即可。
6.PDF模块整合
docling库还有很多参数可以配置,可以去官网学习。
和上面最开始写的解析文件对比一下,添加了模块整合代码,现在是一个完整的pdf解析流程了。
python
import os
from pathlib import Path
from typing import Dict, List, Optional
# 国内直连 huggingface.co 通常超时/被墙,默认走 hf-mirror.com 镜像。
# 用 setdefault 保证:若用户已显式设置 HF_ENDPOINT,则不覆盖。
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
# 关闭 Xet 下载协议:hf-mirror.com 不代理 xethub 的 CAS 服务器,
# 大文件走 Xet 会 401,禁用后回退到普通 HTTP 下载即可走镜像。
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption
from src.parsers.base import BaseParser, ParseResult
from src.parsers.chunker import ChunkStrategy, TextChunker
from src.parsers.cleaner import TextCleaner
from src.parsers.table_parser import TableParser
# 具体实现的解析器类
class PDFParser(BaseParser):
def __init__(self,
chunker : Optional[TextChunker] = None,
cleaner : Optional[TextCleaner] = None,
table_processor : Optional[TableParser] = None
):
# 初始化组件
self.chunker = chunker if chunker else TextChunker() #默认按照段落分块
self.cleaner = cleaner if cleaner else TextCleaner()
self.table_processor = table_processor if table_processor else TableParser()
# 初始化Docling转化器选项
pipeline_options = PipelineOptions()
pipeline_options.do_ocr = False # 关闭OCR
pipeline_options.do_table_structure = True # 启用表格结构
self.converter = DocumentConverter(
format_options={
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)
}
)
def parse(self, file_path: str) -> ParseResult:
"""
解析文档文件
Args:
file_path:文件路径
Returns:
ParseResult:解析结果
"""
# 使用 DocumentConverter 解析 PDF 文件
doc = self.converter.convert(file_path)
# 提取解析结果,raw_text为str类型
raw_text = doc.document.export_to_markdown()
# 提取表格
tables = self._extract_tables(doc)
# 处理数据流程
# 清洗之后的文本数据,类型是str
cleaned_text = self.cleaner.clean(raw_text)
# 合并操作之后的表格列表,类型为 list[dict],单个表是字典
marged_tables= self.table_processor.merge_tables(tables)
# 分块列表,类型为 list[dict],单个块是字典
chunkes = self.chunker.split(cleaned_text,marged_tables)
return ParseResult(
filename=Path(file_path).name,
total_pages=len(doc.pages),
total_chunks=len(chunkes),
table_count=len(marged_tables),
chunks = chunkes,
raw_text=raw_text,
tables=marged_tables,
metadata={'parser':'docling','file_path':file_path}
)
def parse_from_bytes(self, file_bytes: bytes, filename: str) -> ParseResult:
"""
从字节流解析文档
Args:
file_bytes:文件字节流
filename:文件名
Returns:
ParseResult:解析结果
"""
# 使用 DocumentConverter 从字节流解析 PDF 文件
doc = self.converter.convert_from_bytes(file_bytes)
# 提取解析结果
raw_text = doc.document.export_to_markdown()
# 提取表格,类型为 list[dict],单个表是字典
tables = self._extract_tables(doc)
########################################### 处理数据流程
# 清洗之后的文本数据,类型是str
cleaned_text = self.cleaner.clean(raw_text)
# 合并操作之后的表格列表,类型为 list[dict],单个表是字典
marged_tables= self.table_processor.merge_tables(tables)
# 分块列表,类型为 list[dict],单个块是字典
chunkes = self.chunker.split(cleaned_text,marged_tables)
return ParseResult(
filename=filename,
total_pages=len(doc.pages),
total_chunks=len(chunkes),
table_count=len(marged_tables),
chunks = chunkes,
raw_text=raw_text,
tables=marged_tables,
metadata={'parser':'docling','file_path':filename}
)
def supported_extensions(self) -> list[str]:
"""
返回支持的文件扩展名
Returns:
支持的扩展名列表,如['.pdf','.PDF']
"""
return ['.pdf','.PDF']
def _extract_tables(self,doc) -> list[dict]:
'''
提取表格
'''
tables =[]
# 判断有没有tables
if not hasattr(doc.document,'tables') or not doc.document.tables:
return tables
# 遍历所以表格
for i,table in enumerate(doc.document.tables,1):
try:
# 获取页码
prov = getattr(table,'prov',[])
page_no = prov[0].page_no if prov else None
table_data = {
'table_number':i,
# 表格所在的页码
'page':page_no,
'markdown':table.export_to_markdown(doc=doc.document)
}
tables.append(table_data)
except Exception as e:
print(f'警告:无法处理表格:{e}')
continue
return tables