企业级RAG问答系统(一)数据处理模块

目录

一.项目介绍

二.技术选型

三.项目搭建

四.数据处理模块(解析、清洗、分块、整合...)

1.解析器基类

2.PDF解析器

3.文本清洗

4.表格处理

5.智能分块(按段落、句子、字符分块)

6.PDF模块整合


一.项目介绍

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
相关推荐
本原财经1 小时前
大厂争霸AI办公:个人Agent和AI硬件哪个才是答案?
大数据·人工智能
博谷1 小时前
2026GEO概率化观测怎么做?BUGOOAI布谷GEO的采样与指标体系拆解
人工智能·ai
强哥聊算力1 小时前
《算力江湖·国产GPU突围:摩尔线程故事》第四集:从图形到AI:摩尔线程为什么必须冲进大模型赛道?
运维·服务器·人工智能·ai·gpu算力
User_芊芊君子1 小时前
官方 URDF 之外:把启元 Q1 放进 MuJoCo 物理仿真跑起来
安全·ai·大模型
空堂与归2 小时前
百度开源 LoopVL:1B 循环视觉模型省 260 倍数据
人工智能·百度·ai·开源
哔哩哔哩技术2 小时前
Index-Translate:支持 150 种语言的多模态翻译模型是如何炼成的?
人工智能
sinat_286945192 小时前
Claude Code Review
人工智能·代码复审
尘世中一位迷途小书童2 小时前
词表(tokenizer),一个开训就改不了的决定
人工智能
Tiandaren2 小时前
么是agent、为什么要做agent、怎么做agent、怎么工程化
人工智能·python·深度学习·大模型